chore: fixes to jotts-go tui
9024c431
6 file(s) · +188 −62
| 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{ |
|
| 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 | + | } |
| 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 { |
|
| 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 | } |
|
| 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): |
|
| 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 { |
|