chore: fixes to jotts-go tui 9024c431
Steve · 2026-05-16 22:56 6 file(s) · +188 −62
apps/jotts-go/tui/backend.go +7 −7
170 170
	cfg, _ := LoadConfig()
171 171
172 172
	remoteURL := opts.RemoteURL
173 +
	explicitRemote := remoteURL != ""
173 174
	if remoteURL == "" {
174 175
		remoteURL = os.Getenv("JOTTS_REMOTE_URL")
175 176
	}
177 +
	if remoteURL == "" {
178 +
		remoteURL = cfg.RemoteURL
179 +
	}
180 +
176 181
	apiKey := opts.APIKey
177 182
	if apiKey == "" {
178 183
		apiKey = os.Getenv("JOTTS_API_KEY")
182 187
	}
183 188
184 189
	dbPath := opts.DBPath
190 +
	explicitDB := dbPath != ""
185 191
	if dbPath == "" {
186 192
		dbPath = config.Getenv("JOTTS_DB_PATH", "jotts.sqlite")
187 193
	}
188 194
189 -
	useRemote := remoteURL != ""
190 -
	if !useRemote {
191 -
		if _, err := os.Stat(dbPath); err != nil && cfg.RemoteURL != "" {
192 -
			remoteURL = cfg.RemoteURL
193 -
			useRemote = true
194 -
		}
195 -
	}
195 +
	useRemote := remoteURL != "" && (!explicitDB || explicitRemote)
196 196
197 197
	if useRemote {
198 198
		return &RemoteBackend{
apps/jotts-go/tui/backend_test.go (added) +85 −0
1 +
package tui
2 +
3 +
import (
4 +
	"path/filepath"
5 +
	"testing"
6 +
)
7 +
8 +
func writeTestConfig(t *testing.T, dir, remoteURL, apiKey string) {
9 +
	t.Helper()
10 +
	t.Setenv("XDG_CONFIG_HOME", dir)
11 +
	if err := SaveConfig(Config{RemoteURL: remoteURL, APIKey: apiKey}); err != nil {
12 +
		t.Fatalf("save config: %v", err)
13 +
	}
14 +
}
15 +
16 +
func TestResolveBackendUsesConfiguredRemoteByDefault(t *testing.T) {
17 +
	cfgDir := t.TempDir()
18 +
	writeTestConfig(t, cfgDir, "https://notes.example.com", "secret")
19 +
	t.Setenv("JOTTS_REMOTE_URL", "")
20 +
	t.Setenv("JOTTS_API_KEY", "")
21 +
	t.Setenv("JOTTS_DB_PATH", filepath.Join(t.TempDir(), "local.sqlite"))
22 +
23 +
	backend, err := ResolveBackend(Options{})
24 +
	if err != nil {
25 +
		t.Fatalf("resolve backend: %v", err)
26 +
	}
27 +
	defer backend.Close()
28 +
29 +
	remote, ok := backend.(*RemoteBackend)
30 +
	if !ok {
31 +
		t.Fatalf("expected remote backend, got %T", backend)
32 +
	}
33 +
	if remote.BaseURL != "https://notes.example.com" {
34 +
		t.Fatalf("expected configured remote URL, got %q", remote.BaseURL)
35 +
	}
36 +
	if remote.APIKey != "secret" {
37 +
		t.Fatalf("expected configured API key, got %q", remote.APIKey)
38 +
	}
39 +
}
40 +
41 +
func TestResolveBackendExplicitDBOverridesConfiguredRemote(t *testing.T) {
42 +
	cfgDir := t.TempDir()
43 +
	writeTestConfig(t, cfgDir, "https://notes.example.com", "secret")
44 +
	t.Setenv("JOTTS_REMOTE_URL", "")
45 +
	t.Setenv("JOTTS_API_KEY", "")
46 +
47 +
	dbPath := filepath.Join(t.TempDir(), "local.sqlite")
48 +
	backend, err := ResolveBackend(Options{DBPath: dbPath})
49 +
	if err != nil {
50 +
		t.Fatalf("resolve backend: %v", err)
51 +
	}
52 +
	defer backend.Close()
53 +
54 +
	if _, ok := backend.(*LocalBackend); !ok {
55 +
		t.Fatalf("expected local backend, got %T", backend)
56 +
	}
57 +
}
58 +
59 +
func TestResolveBackendExplicitRemoteOverridesExplicitDB(t *testing.T) {
60 +
	cfgDir := t.TempDir()
61 +
	writeTestConfig(t, cfgDir, "https://notes.example.com", "secret")
62 +
	t.Setenv("JOTTS_REMOTE_URL", "")
63 +
	t.Setenv("JOTTS_API_KEY", "")
64 +
65 +
	backend, err := ResolveBackend(Options{
66 +
		RemoteURL: "https://override.example.com",
67 +
		APIKey:    "override-key",
68 +
		DBPath:    filepath.Join(t.TempDir(), "local.sqlite"),
69 +
	})
70 +
	if err != nil {
71 +
		t.Fatalf("resolve backend: %v", err)
72 +
	}
73 +
	defer backend.Close()
74 +
75 +
	remote, ok := backend.(*RemoteBackend)
76 +
	if !ok {
77 +
		t.Fatalf("expected remote backend, got %T", backend)
78 +
	}
79 +
	if remote.BaseURL != "https://override.example.com" {
80 +
		t.Fatalf("expected explicit remote URL, got %q", remote.BaseURL)
81 +
	}
82 +
	if remote.APIKey != "override-key" {
83 +
		t.Fatalf("expected explicit API key, got %q", remote.APIKey)
84 +
	}
85 +
}
apps/jotts-go/tui/model.go +9 −4
53 53
54 54
	width, height int
55 55
	ready         bool
56 -
	loading       bool
57 56
	err           error
58 57
}
59 58
60 -
func newModel(backend Backend) Model {
59 +
func newModel(backend Backend, notes []Note, width, height int) Model {
61 60
	ti := textinput.New()
62 61
	ti.Placeholder = "Title"
63 62
	ti.Prompt = ""
74 73
75 74
	vp := viewport.New(0, 0)
76 75
77 -
	return Model{
76 +
	m := Model{
78 77
		backend:     backend,
79 78
		isRemote:    backend.RemoteURL() != "",
79 +
		notes:       notes,
80 80
		focus:       FocusList,
81 81
		titleInput:  ti,
82 82
		contentArea: ta,
85 85
		help:        help.New(),
86 86
		keys:        defaultKeys(),
87 87
		wrap:        true,
88 +
		width:       width,
89 +
		height:      height,
90 +
		ready:       true,
88 91
	}
92 +
	m.resizePanes()
93 +
	return m
89 94
}
90 95
91 96
func (m Model) Init() tea.Cmd {
92 -
	return loadNotesCmd(m.backend)
97 +
	return tea.WindowSize()
93 98
}
94 99
95 100
func (m *Model) visibleNotes() []Note {
apps/jotts-go/tui/tui.go +14 −1
1 1
package tui
2 2
3 3
import (
4 +
	"os"
5 +
4 6
	tea "github.com/charmbracelet/bubbletea"
7 +
	"golang.org/x/term"
5 8
)
6 9
7 10
func Run(opts Options) error {
11 14
	}
12 15
	defer backend.Close()
13 16
14 -
	p := tea.NewProgram(newModel(backend), tea.WithAltScreen())
17 +
	notes, err := backend.List()
18 +
	if err != nil {
19 +
		return err
20 +
	}
21 +
22 +
	width, height := 100, 28
23 +
	if w, h, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 && h > 0 {
24 +
		width, height = w, h
25 +
	}
26 +
27 +
	p := tea.NewProgram(newModel(backend, notes, width, height), tea.WithAltScreen())
15 28
	_, err = p.Run()
16 29
	return err
17 30
}
apps/jotts-go/tui/update.go +19 −24
47 47
		return m, nil
48 48
49 49
	case notesLoadedMsg:
50 -
		m.loading = false
51 50
		if msg.err != nil {
52 51
			cmd := m.setStatus("load: "+msg.err.Error(), false)
53 52
			return m, cmd
61 60
		if msg.err != nil {
62 61
			return m, m.setStatus("save: "+msg.err.Error(), false)
63 62
		}
63 +
		if m.renderer != nil && msg.note != nil {
64 +
			m.renderer.invalidate(msg.note.ShortID)
65 +
		}
64 66
		m.focus = FocusList
65 67
		m.titleInput.Reset()
66 68
		m.contentArea.Reset()
67 69
		m.editShortID = ""
68 -
		cmds := []tea.Cmd{loadNotesCmd(m.backend), m.setStatus("saved", true)}
69 -
		return m, tea.Batch(cmds...)
70 +
		return m, loadNotesCmd(m.backend)
70 71
71 72
	case noteDeletedMsg:
72 73
		if msg.err != nil {
115 116
	if !m.ready {
116 117
		return
117 118
	}
118 -
	listW := m.width * 30 / 100
119 -
	if listW < 24 {
120 -
		listW = 24
121 -
	}
122 -
	contentW := m.width - listW - 2
123 -
	if contentW < 20 {
124 -
		contentW = 20
125 -
	}
126 -
	bodyH := m.height - 2
127 -
	if bodyH < 5 {
128 -
		bodyH = 5
129 -
	}
119 +
120 +
	_, contentOuterW := splitWidths(m.width)
121 +
	bodyOuterH := splitBodyHeight(m.height)
122 +
	contentInnerW := maxInt(contentOuterW-paneFrameWidth(), 20)
123 +
	contentInnerH := maxInt(bodyOuterH-paneFrameHeight(), 3)
130 124
131 -
	m.contentVP.Width = contentW - 2
132 -
	m.contentVP.Height = bodyH - 2
125 +
	m.contentVP.Width = maxInt(contentInnerW, 1)
126 +
	m.contentVP.Height = maxInt(contentInnerH-1, 1)
133 127
134 -
	m.titleInput.Width = contentW - 4
135 -
	m.contentArea.SetWidth(contentW - 2)
136 -
	m.contentArea.SetHeight(bodyH - 5)
128 +
	m.titleInput.Width = maxInt(contentInnerW-2, 1)
129 +
	m.contentArea.SetWidth(maxInt(contentInnerW, 1))
130 +
	m.contentArea.SetHeight(maxInt(contentInnerH-4, 1))
137 131
138 -
	m.searchInput.Width = listW - 4
132 +
	listOuterW, _ := splitWidths(m.width)
133 +
	listInnerW := maxInt(listOuterW-paneFrameWidth(), 1)
134 +
	m.searchInput.Width = maxInt(listInnerW-2, 1)
139 135
140 136
	if m.renderer == nil {
141 -
		m.renderer = newRenderer(contentW)
137 +
		m.renderer = newRenderer(contentInnerW)
142 138
	} else {
143 -
		m.renderer.resize(contentW)
139 +
		m.renderer.resize(contentInnerW)
144 140
	}
145 141
	m.refreshPreview()
146 142
}
278 274
		m.searchInput.Focus()
279 275
	case key.Matches(msg, m.keys.Refresh):
280 276
		if m.isRemote {
281 -
			m.loading = true
282 277
			return m, loadNotesCmd(m.backend)
283 278
		}
284 279
	case key.Matches(msg, m.keys.Help):
apps/jotts-go/tui/view.go +54 −26
9 9
10 10
var (
11 11
	borderStyle = lipgloss.NewStyle().
12 -
			Border(lipgloss.RoundedBorder()).
12 +
			Border(lipgloss.NormalBorder()).
13 13
			BorderForeground(lipgloss.Color("240"))
14 14
	borderActive = lipgloss.NewStyle().
15 -
			Border(lipgloss.RoundedBorder()).
15 +
			Border(lipgloss.NormalBorder()).
16 16
			BorderForeground(lipgloss.Color("214"))
17 17
	titleStyle = lipgloss.NewStyle().
18 18
			Bold(true).
39 39
)
40 40
41 41
func (m Model) View() string {
42 -
	if !m.ready {
43 -
		return "loading..."
44 -
	}
45 -
46 -
	listW := m.width * 30 / 100
47 -
	if listW < 24 {
48 -
		listW = 24
49 -
	}
50 -
	contentW := m.width - listW - 2
51 -
	bodyH := m.height - 2
42 +
	listW, contentW := splitWidths(m.width)
43 +
	bodyH := splitBodyHeight(m.height)
52 44
53 45
	left := m.renderList(listW, bodyH)
54 46
	right := m.renderRight(contentW, bodyH)
55 47
56 -
	body := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
57 -
	footer := m.renderFooter()
58 -
59 -
	view := lipgloss.JoinVertical(lipgloss.Left, body, footer)
48 +
	view := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
60 49
61 50
	if m.showHelp {
62 51
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
98 87
		rows = append(rows, hintStyle.Render("  (empty — press c)"))
99 88
	}
100 89
	for i, n := range notes {
101 -
		line := truncate(n.Title, w-6)
90 +
		line := truncate(n.Title, maxInt(w-paneFrameWidth()-4, 1))
102 91
		if i == m.cursor {
103 92
			rows = append(rows, itemSelected.Render("▶ "+line))
104 93
		} else {
111 100
	}
112 101
113 102
	content := strings.Join(rows, "\n")
114 -
	return style.Width(w).Height(h).Render(content)
103 +
	return style.
104 +
		Width(maxInt(w-paneFrameWidth(), 1)).
105 +
		Height(maxInt(h-paneFrameHeight(), 1)).
106 +
		Render(content)
115 107
}
116 108
117 109
func (m Model) renderRight(w, h int) string {
134 126
	}
135 127
	body := m.contentVP.View()
136 128
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), body)
137 -
	return style.Width(w).Height(h).Render(inner)
129 +
	return style.
130 +
		Width(maxInt(w-paneFrameWidth(), 1)).
131 +
		Height(maxInt(h-paneFrameHeight(), 1)).
132 +
		Render(inner)
138 133
}
139 134
140 135
func (m Model) renderForm(w, h int) string {
157 152
	}
158 153
159 154
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), title, body)
160 -
	return borderStyle.Width(w).Height(h).Render(inner)
155 +
	return borderStyle.
156 +
		Width(maxInt(w-paneFrameWidth(), 1)).
157 +
		Height(maxInt(h-paneFrameHeight(), 1)).
158 +
		Render(inner)
161 159
}
162 160
163 -
func (m Model) renderFooter() string {
164 -
	mode := "local"
165 -
	if m.isRemote {
166 -
		mode = "remote " + m.backend.RemoteURL()
161 +
func splitWidths(total int) (int, int) {
162 +
	if total < 44 {
163 +
		return total / 2, total - (total / 2)
167 164
	}
168 -
	help := m.help.ShortHelpView(m.keys.ShortHelp())
169 -
	return hintStyle.Render(fmt.Sprintf("[%s] %s", mode, help))
165 +
	list := total * 30 / 100
166 +
	if list < 24 {
167 +
		list = 24
168 +
	}
169 +
	if total-list < 20 {
170 +
		list = total - 20
171 +
	}
172 +
	if list < 1 {
173 +
		list = 1
174 +
	}
175 +
	return list, total - list
176 +
}
177 +
178 +
func splitBodyHeight(total int) int {
179 +
	if total < 3 {
180 +
		return 3
181 +
	}
182 +
	return total
183 +
}
184 +
185 +
func paneFrameWidth() int {
186 +
	return borderStyle.GetHorizontalFrameSize()
187 +
}
188 +
189 +
func paneFrameHeight() int {
190 +
	return borderStyle.GetVerticalFrameSize()
191 +
}
192 +
193 +
func maxInt(a, b int) int {
194 +
	if a > b {
195 +
		return a
196 +
	}
197 +
	return b
170 198
}
171 199
172 200
func truncate(s string, n int) string {