chore: refactored shared tui apps to use tui pkg 57e6e58e
Steve · 2026-05-17 19:42 33 file(s) · +744 −545
apps/jotts-go/go.mod +3 −1
7 7
	charm.land/bubbletea/v2 v2.0.6
8 8
	charm.land/glamour/v2 v2.0.0
9 9
	charm.land/lipgloss/v2 v2.0.3
10 -
	github.com/BurntSushi/toml v1.6.0
11 10
	github.com/atotto/clipboard v0.1.4
12 11
	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
13 12
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
14 13
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
15 14
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
16 15
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
16 +
	github.com/stevedylandev/andromeda/crates-go/tui v0.0.0
17 17
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
18 18
	github.com/yuin/goldmark v1.7.13
19 19
	golang.org/x/term v0.43.0
24 24
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
25 25
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
26 26
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
27 +
	github.com/stevedylandev/andromeda/crates-go/tui => ../../crates-go/tui
27 28
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
28 29
)
29 30
30 31
require (
32 +
	github.com/BurntSushi/toml v1.6.0 // indirect
31 33
	github.com/alecthomas/chroma/v2 v2.20.0 // indirect
32 34
	github.com/aymerick/douceur v0.2.0 // indirect
33 35
	github.com/charmbracelet/colorprofile v0.4.3 // indirect
apps/jotts-go/tui/commands.go +3 −22
4 4
	"strings"
5 5
6 6
	tea "charm.land/bubbletea/v2"
7 -
	"github.com/atotto/clipboard"
8 7
)
9 8
10 9
func loadNotesCmd(b Backend) tea.Cmd {
11 10
	return func() tea.Msg {
12 11
		notes, err := b.List()
13 -
		return notesLoadedMsg{notes: notes, err: err}
12 +
		return notesLoadedMsg{Notes: notes, Err: err}
14 13
	}
15 14
}
16 15
25 24
		} else {
26 25
			note, err = b.Update(shortID, title, content)
27 26
		}
28 -
		return noteSavedMsg{note: note, err: err}
27 +
		return noteSavedMsg{Note: note, Err: err}
29 28
	}
30 29
}
31 30
32 31
func deleteNoteCmd(b Backend, shortID string) tea.Cmd {
33 32
	return func() tea.Msg {
34 33
		_, err := b.Delete(shortID)
35 -
		return noteDeletedMsg{shortID: shortID, err: err}
36 -
	}
37 -
}
38 -
39 -
func copyToClipboardCmd(text, okStatus string) tea.Cmd {
40 -
	return func() tea.Msg {
41 -
		if err := clipboard.WriteAll(text); err != nil {
42 -
			return statusMsg{text: "clipboard: " + err.Error(), ok: false}
43 -
		}
44 -
		return statusMsg{text: okStatus, ok: true}
45 -
	}
46 -
}
47 -
48 -
func openURLCmd(url string) tea.Cmd {
49 -
	return func() tea.Msg {
50 -
		if err := openURL(url); err != nil {
51 -
			return statusMsg{text: "open: " + err.Error(), ok: false}
52 -
		}
53 -
		return statusMsg{text: "opened " + url, ok: true}
34 +
		return noteDeletedMsg{ShortID: shortID, Err: err}
54 35
	}
55 36
}
56 37
apps/jotts-go/tui/config.go +6 −51
1 1
package tui
2 2
3 -
import (
4 -
	"os"
5 -
	"path/filepath"
3 +
import sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
6 4
7 -
	"github.com/BurntSushi/toml"
8 -
)
5 +
const appName = "jotts"
9 6
10 -
type Config struct {
11 -
	RemoteURL string `toml:"remote_url"`
12 -
	APIKey    string `toml:"api_key"`
13 -
}
7 +
type Config = sharedtui.Config
14 8
15 -
func ConfigPath() (string, error) {
16 -
	dir, err := os.UserConfigDir()
17 -
	if err != nil {
18 -
		return "", err
19 -
	}
20 -
	return filepath.Join(dir, "jotts", "config.toml"), nil
21 -
}
22 -
23 -
func LoadConfig() (Config, error) {
24 -
	var cfg Config
25 -
	path, err := ConfigPath()
26 -
	if err != nil {
27 -
		return cfg, err
28 -
	}
29 -
	data, err := os.ReadFile(path)
30 -
	if err != nil {
31 -
		if os.IsNotExist(err) {
32 -
			return cfg, nil
33 -
		}
34 -
		return cfg, err
35 -
	}
36 -
	if err := toml.Unmarshal(data, &cfg); err != nil {
37 -
		return cfg, err
38 -
	}
39 -
	return cfg, nil
40 -
}
41 -
42 -
func SaveConfig(cfg Config) error {
43 -
	path, err := ConfigPath()
44 -
	if err != nil {
45 -
		return err
46 -
	}
47 -
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
48 -
		return err
49 -
	}
50 -
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
51 -
	if err != nil {
52 -
		return err
53 -
	}
54 -
	defer f.Close()
55 -
	return toml.NewEncoder(f).Encode(cfg)
56 -
}
9 +
func ConfigPath() (string, error)    { return sharedtui.ConfigPath(appName) }
10 +
func LoadConfig() (Config, error)    { return sharedtui.LoadConfig(appName) }
11 +
func SaveConfig(cfg Config) error    { return sharedtui.SaveConfig(appName, cfg) }
apps/jotts-go/tui/editor.go +2 −38
1 1
package tui
2 2
3 3
import (
4 -
	"os"
5 -
	"os/exec"
6 -
7 4
	tea "charm.land/bubbletea/v2"
5 +
	sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
8 6
)
9 7
10 8
func openExternalEditor(shortID, content string) tea.Cmd {
11 -
	editor := os.Getenv("EDITOR")
12 -
	if editor == "" {
13 -
		return func() tea.Msg {
14 -
			return statusMsg{text: "$EDITOR not set", ok: false}
15 -
		}
16 -
	}
17 -
18 -
	tmp, err := os.CreateTemp("", "jotts-*.md")
19 -
	if err != nil {
20 -
		return func() tea.Msg {
21 -
			return statusMsg{text: "tempfile: " + err.Error(), ok: false}
22 -
		}
23 -
	}
24 -
	path := tmp.Name()
25 -
	if _, err := tmp.WriteString(content); err != nil {
26 -
		_ = tmp.Close()
27 -
		_ = os.Remove(path)
28 -
		return func() tea.Msg {
29 -
			return statusMsg{text: "tempfile: " + err.Error(), ok: false}
30 -
		}
31 -
	}
32 -
	_ = tmp.Close()
33 -
34 -
	cmd := exec.Command(editor, path)
35 -
	return tea.ExecProcess(cmd, func(err error) tea.Msg {
36 -
		defer os.Remove(path)
37 -
		if err != nil {
38 -
			return editorFinishedMsg{shortID: shortID, err: err}
39 -
		}
40 -
		b, rerr := os.ReadFile(path)
41 -
		if rerr != nil {
42 -
			return editorFinishedMsg{shortID: shortID, err: rerr}
43 -
		}
44 -
		return editorFinishedMsg{shortID: shortID, content: string(b)}
45 -
	})
9 +
	return sharedtui.SpawnEditor(shortID, "jotts-*.md", content)
46 10
}
apps/jotts-go/tui/form_model.go +2 −2
106 106
		case key.Matches(km, f.keys.Save):
107 107
			title := strings.TrimSpace(f.title.Value())
108 108
			if title == "" {
109 -
				return f, func() tea.Msg { return statusMsg{text: "title required", ok: false} }
109 +
				return f, func() tea.Msg { return statusMsg{Text: "title required"} }
110 110
			}
111 111
			return f, func() tea.Msg {
112 -
				return submitFormMsg{shortID: f.shortID, title: title, content: f.content.Value()}
112 +
				return submitFormMsg{ShortID: f.shortID, Title: title, Content: f.content.Value()}
113 113
			}
114 114
		case key.Matches(km, f.keys.SwitchField):
115 115
			if f.field == formFieldTitle {
apps/jotts-go/tui/keys.go +3 −50
1 1
package tui
2 2
3 -
import "charm.land/bubbles/v2/key"
3 +
import sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
4 4
5 -
type keyMap struct {
6 -
	Open        key.Binding
7 -
	Back        key.Binding
8 -
	Quit        key.Binding
9 -
	Create      key.Binding
10 -
	Edit        key.Binding
11 -
	ExtEdit     key.Binding
12 -
	Delete      key.Binding
13 -
	Copy        key.Binding
14 -
	CopyLink    key.Binding
15 -
	OpenBrowser key.Binding
16 -
	Refresh     key.Binding
17 -
	Help        key.Binding
18 -
	ToggleWrap  key.Binding
19 -
	ScrollUp    key.Binding
20 -
	ScrollDown  key.Binding
21 -
}
5 +
type keyMap = sharedtui.KeyMap
22 6
23 -
func defaultKeys() keyMap {
24 -
	return keyMap{
25 -
		Open:        key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")),
26 -
		Back:        key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h/esc", "back")),
27 -
		Quit:        key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
28 -
		Create:      key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
29 -
		Edit:        key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
30 -
		ExtEdit:     key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")),
31 -
		Delete:      key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
32 -
		Copy:        key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")),
33 -
		CopyLink:    key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")),
34 -
		OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")),
35 -
		Refresh:     key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
36 -
		Help:        key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
37 -
		ToggleWrap:  key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")),
38 -
		ScrollUp:    key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
39 -
		ScrollDown:  key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
40 -
	}
41 -
}
42 -
43 -
func (k keyMap) ShortHelp() []key.Binding {
44 -
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Help, k.Quit}
45 -
}
46 -
47 -
func (k keyMap) FullHelp() [][]key.Binding {
48 -
	return [][]key.Binding{
49 -
		{k.Open, k.Back, k.Create, k.Edit},
50 -
		{k.ExtEdit, k.Delete, k.Copy, k.CopyLink},
51 -
		{k.OpenBrowser, k.Refresh, k.ToggleWrap, k.Help},
52 -
		{k.ScrollUp, k.ScrollDown, k.Quit},
53 -
	}
54 -
}
7 +
func defaultKeys() keyMap { return sharedtui.DefaultKeys() }
apps/jotts-go/tui/list_model.go +3 −28
3 3
import (
4 4
	"charm.land/bubbles/v2/list"
5 5
	tea "charm.land/bubbletea/v2"
6 -
	"charm.land/lipgloss/v2"
6 +
	sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
7 7
)
8 8
9 -
func ansiListDelegate() list.DefaultDelegate {
10 -
	d := list.NewDefaultDelegate()
11 -
	d.ShowDescription = false
12 -
	d.SetSpacing(0)
13 -
	d.Styles.NormalTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Padding(0, 0, 0, 2)
14 -
	d.Styles.SelectedTitle = lipgloss.NewStyle().
15 -
		Foreground(lipgloss.Color("3")).
16 -
		Bold(true).
17 -
		Border(lipgloss.NormalBorder(), false, false, false, true).
18 -
		BorderForeground(lipgloss.Color("3")).
19 -
		Padding(0, 0, 0, 1)
20 -
	d.Styles.DimmedTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
21 -
	d.Styles.FilterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
22 -
	return d
23 -
}
24 -
25 -
func ansiListStyles() list.Styles {
26 -
	s := list.DefaultStyles(true)
27 -
	s.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")).Padding(0, 1)
28 -
	s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 0)
29 -
	s.NoItems = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
30 -
	s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
31 -
	return s
32 -
}
33 -
34 9
type noteItem struct {
35 10
	note Note
36 11
}
49 24
		items = append(items, noteItem{note: n})
50 25
	}
51 26
52 -
	l := list.New(items, ansiListDelegate(), 0, 0)
27 +
	l := list.New(items, sharedtui.ANSIListDelegate(), 0, 0)
53 28
	l.Title = "notes"
54 -
	l.Styles = ansiListStyles()
29 +
	l.Styles = sharedtui.ANSIListStyles()
55 30
	l.SetShowStatusBar(false)
56 31
	l.SetShowPagination(false)
57 32
	l.SetShowHelp(false)
apps/jotts-go/tui/messages.go +17 −22
1 1
package tui
2 2
3 +
import sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
4 +
3 5
type notesLoadedMsg struct {
4 -
	notes []Note
5 -
	err   error
6 +
	Notes []Note
7 +
	Err   error
6 8
}
7 9
8 10
type noteSavedMsg struct {
9 -
	note *Note
10 -
	err  error
11 +
	Note *Note
12 +
	Err  error
11 13
}
12 14
13 15
type noteDeletedMsg struct {
14 -
	shortID string
15 -
	err     error
16 -
}
17 -
18 -
type editorFinishedMsg struct {
19 -
	shortID string
20 -
	content string
21 -
	err     error
16 +
	ShortID string
17 +
	Err     error
22 18
}
23 19
24 -
type statusMsg struct {
25 -
	text string
26 -
	ok   bool
27 -
}
28 -
29 -
type clearStatusMsg struct{}
30 -
31 20
type submitFormMsg struct {
32 -
	shortID string
33 -
	title   string
34 -
	content string
21 +
	ShortID string
22 +
	Title   string
23 +
	Content string
35 24
}
36 25
37 26
type cancelFormMsg struct{}
27 +
28 +
type (
29 +
	statusMsg         = sharedtui.StatusMsg
30 +
	clearStatusMsg    = sharedtui.ClearStatusMsg
31 +
	editorFinishedMsg = sharedtui.EditorFinishedMsg
32 +
)
apps/jotts-go/tui/update.go +26 −25
6 6
7 7
	"charm.land/bubbles/v2/key"
8 8
	tea "charm.land/bubbletea/v2"
9 +
	sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
9 10
)
10 11
11 12
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
18 19
		return m, nil
19 20
20 21
	case notesLoadedMsg:
21 -
		if msg.err != nil {
22 -
			return m, m.setStatus("load: "+msg.err.Error(), false)
22 +
		if msg.Err != nil {
23 +
			return m, m.setStatus("load: "+msg.Err.Error(), false)
23 24
		}
24 -
		cmd := m.list.SetNotes(msg.notes)
25 +
		cmd := m.list.SetNotes(msg.Notes)
25 26
		if n, ok := m.list.Selected(); ok {
26 27
			m.cont.SetNote(&n)
27 28
		} else {
30 31
		return m, cmd
31 32
32 33
	case noteSavedMsg:
33 -
		if msg.err != nil {
34 -
			return m, m.setStatus("save: "+msg.err.Error(), false)
34 +
		if msg.Err != nil {
35 +
			return m, m.setStatus("save: "+msg.Err.Error(), false)
35 36
		}
36 -
		if msg.note != nil {
37 -
			m.cont.Invalidate(msg.note.ShortID)
37 +
		if msg.Note != nil {
38 +
			m.cont.Invalidate(msg.Note.ShortID)
38 39
		}
39 40
		m.state = stateList
40 41
		m.form.Blur()
41 42
		return m, tea.Batch(loadNotesCmd(m.backend), m.setStatus("saved", true))
42 43
43 44
	case noteDeletedMsg:
44 -
		if msg.err != nil {
45 -
			return m, m.setStatus("delete: "+msg.err.Error(), false)
45 +
		if msg.Err != nil {
46 +
			return m, m.setStatus("delete: "+msg.Err.Error(), false)
46 47
		}
47 -
		m.cont.Invalidate(msg.shortID)
48 +
		m.cont.Invalidate(msg.ShortID)
48 49
		m.state = stateList
49 50
		return m, tea.Batch(loadNotesCmd(m.backend), m.setStatus("deleted", true))
50 51
51 52
	case editorFinishedMsg:
52 -
		if msg.err != nil {
53 -
			return m, m.setStatus("editor: "+msg.err.Error(), false)
53 +
		if msg.Err != nil {
54 +
			return m, m.setStatus("editor: "+msg.Err.Error(), false)
54 55
		}
55 -
		if msg.shortID == "" {
56 -
			m.form.SetContent(msg.content)
56 +
		if msg.Tag == "" {
57 +
			m.form.SetContent(msg.Content)
57 58
			return m, nil
58 59
		}
59 60
		var orig *Note
60 61
		for _, it := range m.list.inner.Items() {
61 62
			ni, ok := it.(noteItem)
62 -
			if ok && ni.note.ShortID == msg.shortID {
63 +
			if ok && ni.note.ShortID == msg.Tag {
63 64
				n := ni.note
64 65
				orig = &n
65 66
				break
66 67
			}
67 68
		}
68 -
		if orig == nil || strings.TrimRight(orig.Content, "\n") == strings.TrimRight(msg.content, "\n") {
69 +
		if orig == nil || strings.TrimRight(orig.Content, "\n") == strings.TrimRight(msg.Content, "\n") {
69 70
			return m, nil
70 71
		}
71 -
		return m, saveNoteCmd(m.backend, msg.shortID, orig.Title, msg.content)
72 +
		return m, saveNoteCmd(m.backend, msg.Tag, orig.Title, msg.Content)
72 73
73 74
	case submitFormMsg:
74 -
		return m, saveNoteCmd(m.backend, msg.shortID, msg.title, msg.content)
75 +
		return m, saveNoteCmd(m.backend, msg.ShortID, msg.Title, msg.Content)
75 76
76 77
	case cancelFormMsg:
77 78
		m.state = stateList
78 79
		return m, nil
79 80
80 81
	case statusMsg:
81 -
		return m, m.setStatus(msg.text, msg.ok)
82 +
		return m, m.setStatus(msg.Text, msg.OK)
82 83
83 84
	case clearStatusMsg:
84 85
		if time.Now().Before(m.statusUntil) {
171 172
		return m, nil
172 173
	case key.Matches(msg, m.keys.Copy):
173 174
		if n, ok := m.list.Selected(); ok {
174 -
			return m, copyToClipboardCmd(n.Content, "copied text")
175 +
			return m, sharedtui.CopyToClipboardCmd(n.Content, "copied text")
175 176
		}
176 177
		return m, nil
177 178
	case key.Matches(msg, m.keys.CopyLink):
179 180
			return m, m.setStatus("local mode: no link", false)
180 181
		}
181 182
		if n, ok := m.list.Selected(); ok {
182 -
			return m, copyToClipboardCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID), "copied link")
183 +
			return m, sharedtui.CopyToClipboardCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID), "copied link")
183 184
		}
184 185
		return m, nil
185 186
	case key.Matches(msg, m.keys.OpenBrowser):
187 188
			return m, nil
188 189
		}
189 190
		if n, ok := m.list.Selected(); ok {
190 -
			return m, openURLCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID))
191 +
			return m, sharedtui.OpenURLCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID))
191 192
		}
192 193
		return m, nil
193 194
	case key.Matches(msg, m.keys.Refresh):
230 231
		return m, nil
231 232
	case key.Matches(msg, m.keys.Copy):
232 233
		if n, ok := m.list.Selected(); ok {
233 -
			return m, copyToClipboardCmd(n.Content, "copied text")
234 +
			return m, sharedtui.CopyToClipboardCmd(n.Content, "copied text")
234 235
		}
235 236
		return m, nil
236 237
	case key.Matches(msg, m.keys.CopyLink):
238 239
			return m, m.setStatus("local mode: no link", false)
239 240
		}
240 241
		if n, ok := m.list.Selected(); ok {
241 -
			return m, copyToClipboardCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID), "copied link")
242 +
			return m, sharedtui.CopyToClipboardCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID), "copied link")
242 243
		}
243 244
		return m, nil
244 245
	case key.Matches(msg, m.keys.OpenBrowser):
246 247
			return m, nil
247 248
		}
248 249
		if n, ok := m.list.Selected(); ok {
249 -
			return m, openURLCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID))
250 +
			return m, sharedtui.OpenURLCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID))
250 251
		}
251 252
		return m, nil
252 253
	case key.Matches(msg, m.keys.ToggleWrap):
apps/jotts-go/tui/view.go +10 −23
5 5
6 6
	tea "charm.land/bubbletea/v2"
7 7
	"charm.land/lipgloss/v2"
8 +
	sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
8 9
)
9 10
10 11
var (
11 -
	borderStyle = lipgloss.NewStyle().
12 -
			Border(lipgloss.NormalBorder()).
13 -
			BorderForeground(lipgloss.Color("8"))
14 -
	borderActive = lipgloss.NewStyle().
15 -
			Border(lipgloss.NormalBorder()).
16 -
			BorderForeground(lipgloss.Color("3"))
17 -
	titleStyle = lipgloss.NewStyle().
18 -
			Bold(true).
19 -
			Foreground(lipgloss.Color("3")).
20 -
			Padding(0, 1)
21 -
	statusOKStyle = lipgloss.NewStyle().
22 -
			Foreground(lipgloss.Color("2")).
23 -
			Bold(true)
24 -
	statusErrStyle = lipgloss.NewStyle().
25 -
			Foreground(lipgloss.Color("1")).
26 -
			Bold(true)
27 -
	hintStyle = lipgloss.NewStyle().
28 -
			Foreground(lipgloss.Color("8"))
29 -
	modalStyle = lipgloss.NewStyle().
30 -
			Border(lipgloss.RoundedBorder()).
31 -
			BorderForeground(lipgloss.Color("3")).
32 -
			Padding(1, 2)
12 +
	borderStyle    = sharedtui.Border(lipgloss.NormalBorder())
13 +
	borderActive   = sharedtui.BorderActive(lipgloss.NormalBorder())
14 +
	titleStyle     = sharedtui.TitleStyle
15 +
	statusOKStyle  = sharedtui.StatusOKStyle
16 +
	statusErrStyle = sharedtui.StatusErrStyle
17 +
	hintStyle      = sharedtui.HintStyle
18 +
	modalStyle       = sharedtui.ModalStyle
19 +
	statusModalStyle = sharedtui.StatusModalStyle
33 20
)
34 21
35 22
func (m Model) View() tea.View {
62 49
			st = statusErrStyle
63 50
		}
64 51
		overlays = append(overlays, bottomCenterLayer(m.width, m.height,
65 -
			modalStyle.Render(st.Render(m.status)), 3))
52 +
			statusModalStyle.Render(st.Render(m.status)), 3))
66 53
	}
67 54
68 55
	content := base
apps/sipp-go/go.mod +4 −2
6 6
	charm.land/bubbles/v2 v2.1.0
7 7
	charm.land/bubbletea/v2 v2.0.6
8 8
	charm.land/lipgloss/v2 v2.0.3
9 -
	github.com/BurntSushi/toml v1.6.0
10 9
	github.com/alecthomas/chroma/v2 v2.14.0
11 10
	github.com/atotto/clipboard v0.1.4
12 11
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
13 12
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
14 13
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
15 14
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
15 +
	github.com/stevedylandev/andromeda/crates-go/tui v0.0.0
16 16
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
17 +
	golang.org/x/term v0.43.0
17 18
)
18 19
19 20
require (
21 +
	github.com/BurntSushi/toml v1.6.0 // indirect
20 22
	github.com/charmbracelet/colorprofile v0.4.3 // indirect
21 23
	github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
22 24
	github.com/charmbracelet/x/ansi v0.11.7 // indirect
42 44
	golang.org/x/mod v0.25.0 // indirect
43 45
	golang.org/x/sync v0.20.0 // indirect
44 46
	golang.org/x/sys v0.44.0 // indirect
45 -
	golang.org/x/term v0.43.0 // indirect
46 47
	modernc.org/libc v1.65.7 // indirect
47 48
	modernc.org/mathutil v1.7.1 // indirect
48 49
	modernc.org/memory v1.11.0 // indirect
54 55
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
55 56
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
56 57
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
58 +
	github.com/stevedylandev/andromeda/crates-go/tui => ../../crates-go/tui
57 59
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
58 60
)
apps/sipp-go/go.sum +0 −2
75 75
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
76 76
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
77 77
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
78 -
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
79 -
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
80 78
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
81 79
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
82 80
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
apps/sipp-go/tui/commands.go +3 −22
4 4
	"strings"
5 5
6 6
	tea "charm.land/bubbletea/v2"
7 -
	"github.com/atotto/clipboard"
8 7
)
9 8
10 9
func loadSnippetsCmd(b Backend) tea.Cmd {
11 10
	return func() tea.Msg {
12 11
		list, err := b.List()
13 -
		return snippetsLoadedMsg{snippets: list, err: err}
12 +
		return snippetsLoadedMsg{Snippets: list, Err: err}
14 13
	}
15 14
}
16 15
25 24
		} else {
26 25
			s, err = b.Update(shortID, name, content)
27 26
		}
28 -
		return snippetSavedMsg{snippet: s, err: err}
27 +
		return snippetSavedMsg{Snippet: s, Err: err}
29 28
	}
30 29
}
31 30
32 31
func deleteSnippetCmd(b Backend, shortID string) tea.Cmd {
33 32
	return func() tea.Msg {
34 33
		_, err := b.Delete(shortID)
35 -
		return snippetDeletedMsg{shortID: shortID, err: err}
36 -
	}
37 -
}
38 -
39 -
func copyToClipboardCmd(text, okStatus string) tea.Cmd {
40 -
	return func() tea.Msg {
41 -
		if err := clipboard.WriteAll(text); err != nil {
42 -
			return statusMsg{text: "clipboard: " + err.Error(), ok: false}
43 -
		}
44 -
		return statusMsg{text: okStatus, ok: true}
45 -
	}
46 -
}
47 -
48 -
func openURLCmd(url string) tea.Cmd {
49 -
	return func() tea.Msg {
50 -
		if err := openURL(url); err != nil {
51 -
			return statusMsg{text: "open: " + err.Error(), ok: false}
52 -
		}
53 -
		return statusMsg{text: "opened " + url, ok: true}
34 +
		return snippetDeletedMsg{ShortID: shortID, Err: err}
54 35
	}
55 36
}
56 37
apps/sipp-go/tui/config.go +6 −51
1 1
package tui
2 2
3 -
import (
4 -
	"os"
5 -
	"path/filepath"
3 +
import sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
6 4
7 -
	"github.com/BurntSushi/toml"
8 -
)
5 +
const appName = "sipp"
9 6
10 -
type Config struct {
11 -
	RemoteURL string `toml:"remote_url"`
12 -
	APIKey    string `toml:"api_key"`
13 -
}
7 +
type Config = sharedtui.Config
14 8
15 -
func ConfigPath() (string, error) {
16 -
	dir, err := os.UserConfigDir()
17 -
	if err != nil {
18 -
		return "", err
19 -
	}
20 -
	return filepath.Join(dir, "sipp", "config.toml"), nil
21 -
}
22 -
23 -
func LoadConfig() (Config, error) {
24 -
	var cfg Config
25 -
	path, err := ConfigPath()
26 -
	if err != nil {
27 -
		return cfg, err
28 -
	}
29 -
	data, err := os.ReadFile(path)
30 -
	if err != nil {
31 -
		if os.IsNotExist(err) {
32 -
			return cfg, nil
33 -
		}
34 -
		return cfg, err
35 -
	}
36 -
	if err := toml.Unmarshal(data, &cfg); err != nil {
37 -
		return cfg, err
38 -
	}
39 -
	return cfg, nil
40 -
}
41 -
42 -
func SaveConfig(cfg Config) error {
43 -
	path, err := ConfigPath()
44 -
	if err != nil {
45 -
		return err
46 -
	}
47 -
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
48 -
		return err
49 -
	}
50 -
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
51 -
	if err != nil {
52 -
		return err
53 -
	}
54 -
	defer f.Close()
55 -
	return toml.NewEncoder(f).Encode(cfg)
56 -
}
9 +
func ConfigPath() (string, error) { return sharedtui.ConfigPath(appName) }
10 +
func LoadConfig() (Config, error) { return sharedtui.LoadConfig(appName) }
11 +
func SaveConfig(cfg Config) error { return sharedtui.SaveConfig(appName, cfg) }
apps/sipp-go/tui/config_test.go → crates-go/tui/config_test.go +4 −4
10 10
11 11
func TestConfigTOMLRoundTrip(t *testing.T) {
12 12
	cfg := Config{RemoteURL: "https://example.test", APIKey: "secret"}
13 -
	var path = filepath.Join(t.TempDir(), "config.toml")
13 +
	path := filepath.Join(t.TempDir(), "config.toml")
14 14
	f, err := os.Create(path)
15 15
	if err != nil {
16 16
		t.Fatal(err)
33 33
func TestLoadConfigMissingAndSaveRoundTrip(t *testing.T) {
34 34
	dir := t.TempDir()
35 35
	t.Setenv("XDG_CONFIG_HOME", dir)
36 -
	cfg, err := LoadConfig()
36 +
	cfg, err := LoadConfig("testapp")
37 37
	if err != nil {
38 38
		t.Fatal(err)
39 39
	}
42 42
	}
43 43
44 44
	want := Config{RemoteURL: "http://localhost:3000", APIKey: "key"}
45 -
	if err := SaveConfig(want); err != nil {
45 +
	if err := SaveConfig("testapp", want); err != nil {
46 46
		t.Fatal(err)
47 47
	}
48 -
	got, err := LoadConfig()
48 +
	got, err := LoadConfig("testapp")
49 49
	if err != nil {
50 50
		t.Fatal(err)
51 51
	}
apps/sipp-go/tui/editor.go +3 −45
1 1
package tui
2 2
3 3
import (
4 -
	"fmt"
5 -
	"os"
6 -
	"os/exec"
7 4
	"path/filepath"
8 -
	"runtime"
9 5
10 6
	tea "charm.land/bubbletea/v2"
7 +
	sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
11 8
)
12 9
13 10
func openExternalEditor(shortID, name, content string) tea.Cmd {
14 -
	editor := os.Getenv("EDITOR")
15 -
	if editor == "" {
16 -
		return func() tea.Msg {
17 -
			return statusMsg{text: "$EDITOR not set", ok: false}
18 -
		}
19 -
	}
20 -
21 11
	base := name
22 12
	if base == "" {
23 13
		base = "snippet.txt"
24 14
	}
25 -
	tmp := filepath.Join(os.TempDir(), fmt.Sprintf("sipp-%s-%s", shortID, filepath.Base(base)))
26 -
	if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
27 -
		return func() tea.Msg {
28 -
			return statusMsg{text: "tempfile: " + err.Error(), ok: false}
29 -
		}
30 -
	}
31 -
32 -
	cmd := exec.Command(editor, tmp)
33 -
	return tea.ExecProcess(cmd, func(err error) tea.Msg {
34 -
		defer os.Remove(tmp)
35 -
		if err != nil {
36 -
			return editorFinishedMsg{shortID: shortID, err: err}
37 -
		}
38 -
		b, rerr := os.ReadFile(tmp)
39 -
		if rerr != nil {
40 -
			return editorFinishedMsg{shortID: shortID, err: rerr}
41 -
		}
42 -
		return editorFinishedMsg{shortID: shortID, content: string(b)}
43 -
	})
44 -
}
45 -
46 -
func openURL(url string) error {
47 -
	var cmd *exec.Cmd
48 -
	switch runtime.GOOS {
49 -
	case "linux":
50 -
		cmd = exec.Command("xdg-open", url)
51 -
	case "darwin":
52 -
		cmd = exec.Command("open", url)
53 -
	case "windows":
54 -
		cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
55 -
	default:
56 -
		return fmt.Errorf("unsupported platform %s", runtime.GOOS)
57 -
	}
58 -
	return cmd.Start()
15 +
	pattern := "sipp-" + shortID + "-*-" + filepath.Base(base)
16 +
	return sharedtui.SpawnEditor(shortID, pattern, content)
59 17
}
apps/sipp-go/tui/form_model.go +3 −3
106 106
		case key.Matches(km, f.keys.Save):
107 107
			name := strings.TrimSpace(f.name.Value())
108 108
			if name == "" {
109 -
				return f, func() tea.Msg { return statusMsg{text: "name required", ok: false} }
109 +
				return f, func() tea.Msg { return statusMsg{Text: "name required"} }
110 110
			}
111 111
			content := f.content.Value()
112 112
			if strings.TrimSpace(content) == "" {
113 -
				return f, func() tea.Msg { return statusMsg{text: "content required", ok: false} }
113 +
				return f, func() tea.Msg { return statusMsg{Text: "content required"} }
114 114
			}
115 115
			return f, func() tea.Msg {
116 -
				return submitFormMsg{shortID: f.shortID, name: name, content: content}
116 +
				return submitFormMsg{ShortID: f.shortID, Name: name, Content: content}
117 117
			}
118 118
		case key.Matches(km, f.keys.SwitchField):
119 119
			if f.field == formFieldName {
apps/sipp-go/tui/keys.go +3 −50
1 1
package tui
2 2
3 -
import "charm.land/bubbles/v2/key"
3 +
import sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
4 4
5 -
type keyMap struct {
6 -
	Open        key.Binding
7 -
	Back        key.Binding
8 -
	Quit        key.Binding
9 -
	Create      key.Binding
10 -
	Edit        key.Binding
11 -
	ExtEdit     key.Binding
12 -
	Delete      key.Binding
13 -
	Copy        key.Binding
14 -
	CopyLink    key.Binding
15 -
	OpenBrowser key.Binding
16 -
	Refresh     key.Binding
17 -
	WrapToggle  key.Binding
18 -
	Help        key.Binding
19 -
	ScrollUp    key.Binding
20 -
	ScrollDown  key.Binding
21 -
}
5 +
type keyMap = sharedtui.KeyMap
22 6
23 -
func defaultKeys() keyMap {
24 -
	return keyMap{
25 -
		Open:        key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")),
26 -
		Back:        key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h/esc", "back")),
27 -
		Quit:        key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
28 -
		Create:      key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
29 -
		Edit:        key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
30 -
		ExtEdit:     key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")),
31 -
		Delete:      key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
32 -
		Copy:        key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")),
33 -
		CopyLink:    key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")),
34 -
		OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")),
35 -
		Refresh:     key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
36 -
		WrapToggle:  key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")),
37 -
		Help:        key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
38 -
		ScrollUp:    key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
39 -
		ScrollDown:  key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
40 -
	}
41 -
}
42 -
43 -
func (k keyMap) ShortHelp() []key.Binding {
44 -
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Help, k.Quit}
45 -
}
46 -
47 -
func (k keyMap) FullHelp() [][]key.Binding {
48 -
	return [][]key.Binding{
49 -
		{k.Open, k.Back, k.Create, k.Edit},
50 -
		{k.ExtEdit, k.Delete, k.Copy, k.CopyLink},
51 -
		{k.OpenBrowser, k.Refresh, k.WrapToggle, k.Help},
52 -
		{k.ScrollUp, k.ScrollDown, k.Quit},
53 -
	}
54 -
}
7 +
func defaultKeys() keyMap { return sharedtui.DefaultKeys() }
apps/sipp-go/tui/list_model.go +4 −29
3 3
import (
4 4
	"charm.land/bubbles/v2/list"
5 5
	tea "charm.land/bubbletea/v2"
6 -
	"charm.land/lipgloss/v2"
6 +
	sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
7 7
)
8 8
9 -
var listIDStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
10 -
11 -
func ansiListDelegate() list.DefaultDelegate {
12 -
	d := list.NewDefaultDelegate()
13 -
	d.ShowDescription = false
14 -
	d.SetSpacing(0)
15 -
	d.Styles.NormalTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Padding(0, 0, 0, 2)
16 -
	d.Styles.SelectedTitle = lipgloss.NewStyle().
17 -
		Foreground(lipgloss.Color("3")).
18 -
		Bold(true).
19 -
		Border(lipgloss.NormalBorder(), false, false, false, true).
20 -
		BorderForeground(lipgloss.Color("3")).
21 -
		Padding(0, 0, 0, 1)
22 -
	d.Styles.DimmedTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
23 -
	d.Styles.FilterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
24 -
	return d
25 -
}
26 -
27 -
func ansiListStyles() list.Styles {
28 -
	s := list.DefaultStyles(true)
29 -
	s.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")).Padding(0, 1)
30 -
	s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 0)
31 -
	s.NoItems = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
32 -
	s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
33 -
	return s
34 -
}
9 +
var listIDStyle = sharedtui.ListIDStyle
35 10
36 11
type snippetItem struct {
37 12
	snippet Snippet
63 38
		items = append(items, snippetItem{snippet: s})
64 39
	}
65 40
66 -
	l := list.New(items, ansiListDelegate(), 0, 0)
41 +
	l := list.New(items, sharedtui.ANSIListDelegate(), 0, 0)
67 42
	l.Title = "snippets"
68 -
	l.Styles = ansiListStyles()
43 +
	l.Styles = sharedtui.ANSIListStyles()
69 44
	l.SetShowStatusBar(false)
70 45
	l.SetShowPagination(false)
71 46
	l.SetShowHelp(false)
apps/sipp-go/tui/messages.go +17 −22
1 1
package tui
2 2
3 +
import sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
4 +
3 5
type snippetsLoadedMsg struct {
4 -
	snippets []Snippet
5 -
	err      error
6 +
	Snippets []Snippet
7 +
	Err      error
6 8
}
7 9
8 10
type snippetSavedMsg struct {
9 -
	snippet *Snippet
10 -
	err     error
11 +
	Snippet *Snippet
12 +
	Err     error
11 13
}
12 14
13 15
type snippetDeletedMsg struct {
14 -
	shortID string
15 -
	err     error
16 -
}
17 -
18 -
type editorFinishedMsg struct {
19 -
	shortID string
20 -
	content string
21 -
	err     error
16 +
	ShortID string
17 +
	Err     error
22 18
}
23 19
24 -
type statusMsg struct {
25 -
	text string
26 -
	ok   bool
27 -
}
28 -
29 -
type clearStatusMsg struct{}
30 -
31 20
type submitFormMsg struct {
32 -
	shortID string
33 -
	name    string
34 -
	content string
21 +
	ShortID string
22 +
	Name    string
23 +
	Content string
35 24
}
36 25
37 26
type cancelFormMsg struct{}
27 +
28 +
type (
29 +
	statusMsg         = sharedtui.StatusMsg
30 +
	clearStatusMsg    = sharedtui.ClearStatusMsg
31 +
	editorFinishedMsg = sharedtui.EditorFinishedMsg
32 +
)
apps/sipp-go/tui/update.go +27 −26
6 6
7 7
	"charm.land/bubbles/v2/key"
8 8
	tea "charm.land/bubbletea/v2"
9 +
	sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
9 10
)
10 11
11 12
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19 20
20 21
	case snippetsLoadedMsg:
21 22
		m.loading = false
22 -
		if msg.err != nil {
23 -
			return m, m.setStatus("load: "+msg.err.Error(), false)
23 +
		if msg.Err != nil {
24 +
			return m, m.setStatus("load: "+msg.Err.Error(), false)
24 25
		}
25 -
		cmd := m.list.SetSnippets(msg.snippets)
26 +
		cmd := m.list.SetSnippets(msg.Snippets)
26 27
		m.refreshContentFromSelection()
27 28
		return m, cmd
28 29
29 30
	case snippetSavedMsg:
30 -
		if msg.err != nil {
31 -
			return m, m.setStatus("save: "+msg.err.Error(), false)
31 +
		if msg.Err != nil {
32 +
			return m, m.setStatus("save: "+msg.Err.Error(), false)
32 33
		}
33 -
		if msg.snippet != nil {
34 -
			m.cont.Invalidate(msg.snippet.ShortID)
34 +
		if msg.Snippet != nil {
35 +
			m.cont.Invalidate(msg.Snippet.ShortID)
35 36
		}
36 37
		m.state = stateList
37 38
		m.form.Blur()
38 39
		return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("saved", true))
39 40
40 41
	case snippetDeletedMsg:
41 -
		if msg.err != nil {
42 -
			return m, m.setStatus("delete: "+msg.err.Error(), false)
42 +
		if msg.Err != nil {
43 +
			return m, m.setStatus("delete: "+msg.Err.Error(), false)
43 44
		}
44 -
		m.cont.Invalidate(msg.shortID)
45 +
		m.cont.Invalidate(msg.ShortID)
45 46
		m.state = stateList
46 47
		return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("deleted", true))
47 48
48 49
	case editorFinishedMsg:
49 -
		if msg.err != nil {
50 -
			return m, m.setStatus("editor: "+msg.err.Error(), false)
50 +
		if msg.Err != nil {
51 +
			return m, m.setStatus("editor: "+msg.Err.Error(), false)
51 52
		}
52 -
		if msg.shortID == "" {
53 -
			m.form.SetContent(msg.content)
53 +
		if msg.Tag == "" {
54 +
			m.form.SetContent(msg.Content)
54 55
			return m, nil
55 56
		}
56 57
		var orig *Snippet
57 58
		for _, it := range m.list.inner.Items() {
58 59
			si, ok := it.(snippetItem)
59 -
			if ok && si.snippet.ShortID == msg.shortID {
60 +
			if ok && si.snippet.ShortID == msg.Tag {
60 61
				s := si.snippet
61 62
				orig = &s
62 63
				break
63 64
			}
64 65
		}
65 -
		if orig == nil || strings.TrimRight(orig.Content, "\n") == strings.TrimRight(msg.content, "\n") {
66 +
		if orig == nil || strings.TrimRight(orig.Content, "\n") == strings.TrimRight(msg.Content, "\n") {
66 67
			return m, nil
67 68
		}
68 -
		return m, saveSnippetCmd(m.backend, msg.shortID, orig.Name, msg.content)
69 +
		return m, saveSnippetCmd(m.backend, msg.Tag, orig.Name, msg.Content)
69 70
70 71
	case submitFormMsg:
71 -
		return m, saveSnippetCmd(m.backend, msg.shortID, msg.name, msg.content)
72 +
		return m, saveSnippetCmd(m.backend, msg.ShortID, msg.Name, msg.Content)
72 73
73 74
	case cancelFormMsg:
74 75
		m.state = stateList
75 76
		return m, nil
76 77
77 78
	case statusMsg:
78 -
		return m, m.setStatus(msg.text, msg.ok)
79 +
		return m, m.setStatus(msg.Text, msg.OK)
79 80
80 81
	case clearStatusMsg:
81 82
		if time.Now().Before(m.statusUntil) {
170 171
		return m, nil
171 172
	case key.Matches(msg, m.keys.Copy):
172 173
		if s, ok := m.list.Selected(); ok {
173 -
			return m, copyToClipboardCmd(s.Content, "copied text")
174 +
			return m, sharedtui.CopyToClipboardCmd(s.Content, "copied text")
174 175
		}
175 176
		return m, nil
176 177
	case key.Matches(msg, m.keys.CopyLink):
178 179
			return m, m.setStatus("local mode: no link", false)
179 180
		}
180 181
		if s, ok := m.list.Selected(); ok {
181 -
			return m, copyToClipboardCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID), "copied link")
182 +
			return m, sharedtui.CopyToClipboardCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID), "copied link")
182 183
		}
183 184
		return m, nil
184 185
	case key.Matches(msg, m.keys.OpenBrowser):
186 187
			return m, nil
187 188
		}
188 189
		if s, ok := m.list.Selected(); ok {
189 -
			return m, openURLCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID))
190 +
			return m, sharedtui.OpenURLCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID))
190 191
		}
191 192
		return m, nil
192 193
	case key.Matches(msg, m.keys.Refresh):
205 206
206 207
func (m Model) handleContentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
207 208
	switch {
208 -
	case key.Matches(msg, m.keys.WrapToggle):
209 +
	case key.Matches(msg, m.keys.ToggleWrap):
209 210
		m.cont.ToggleWrap()
210 211
		if m.cont.Wrap() {
211 212
			return m, m.setStatus("wrap on", true)
233 234
		return m, nil
234 235
	case key.Matches(msg, m.keys.Copy):
235 236
		if s, ok := m.list.Selected(); ok {
236 -
			return m, copyToClipboardCmd(s.Content, "copied text")
237 +
			return m, sharedtui.CopyToClipboardCmd(s.Content, "copied text")
237 238
		}
238 239
		return m, nil
239 240
	case key.Matches(msg, m.keys.CopyLink):
241 242
			return m, m.setStatus("local mode: no link", false)
242 243
		}
243 244
		if s, ok := m.list.Selected(); ok {
244 -
			return m, copyToClipboardCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID), "copied link")
245 +
			return m, sharedtui.CopyToClipboardCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID), "copied link")
245 246
		}
246 247
		return m, nil
247 248
	case key.Matches(msg, m.keys.OpenBrowser):
249 250
			return m, nil
250 251
		}
251 252
		if s, ok := m.list.Selected(); ok {
252 -
			return m, openURLCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID))
253 +
			return m, sharedtui.OpenURLCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID))
253 254
		}
254 255
		return m, nil
255 256
	case key.Matches(msg, m.keys.Help):
apps/sipp-go/tui/view.go +10 −23
5 5
6 6
	tea "charm.land/bubbletea/v2"
7 7
	"charm.land/lipgloss/v2"
8 +
	sharedtui "github.com/stevedylandev/andromeda/crates-go/tui"
8 9
)
9 10
10 11
var (
11 -
	borderStyle = lipgloss.NewStyle().
12 -
			Border(lipgloss.RoundedBorder()).
13 -
			BorderForeground(lipgloss.Color("8"))
14 -
	borderActive = lipgloss.NewStyle().
15 -
			Border(lipgloss.RoundedBorder()).
16 -
			BorderForeground(lipgloss.Color("3"))
17 -
	titleStyle = lipgloss.NewStyle().
18 -
			Bold(true).
19 -
			Foreground(lipgloss.Color("3")).
20 -
			Padding(0, 1)
21 -
	statusOKStyle = lipgloss.NewStyle().
22 -
			Foreground(lipgloss.Color("2")).
23 -
			Bold(true)
24 -
	statusErrStyle = lipgloss.NewStyle().
25 -
			Foreground(lipgloss.Color("1")).
26 -
			Bold(true)
27 -
	hintStyle = lipgloss.NewStyle().
28 -
			Foreground(lipgloss.Color("8"))
29 -
	modalStyle = lipgloss.NewStyle().
30 -
			Border(lipgloss.RoundedBorder()).
31 -
			BorderForeground(lipgloss.Color("3")).
32 -
			Padding(1, 2)
12 +
	borderStyle    = sharedtui.Border(lipgloss.RoundedBorder())
13 +
	borderActive   = sharedtui.BorderActive(lipgloss.RoundedBorder())
14 +
	titleStyle     = sharedtui.TitleStyle
15 +
	statusOKStyle  = sharedtui.StatusOKStyle
16 +
	statusErrStyle = sharedtui.StatusErrStyle
17 +
	hintStyle      = sharedtui.HintStyle
18 +
	modalStyle       = sharedtui.ModalStyle
19 +
	statusModalStyle = sharedtui.StatusModalStyle
33 20
)
34 21
35 22
func (m Model) View() tea.View {
73 60
			st = statusErrStyle
74 61
		}
75 62
		overlays = append(overlays, bottomCenterLayer(m.width, m.height,
76 -
			modalStyle.Render(st.Render(m.status)), 3))
63 +
			statusModalStyle.Render(st.Render(m.status)), 3))
77 64
	}
78 65
79 66
	content := base
crates-go/auth/auth.go +16 −4
104 104
		Path:     "/",
105 105
		HttpOnly: true,
106 106
		Secure:   s.CookieSecure,
107 -
		SameSite: http.SameSiteLaxMode,
107 +
		SameSite: http.SameSiteStrictMode,
108 108
		MaxAge:   int(s.maxAge().Seconds()),
109 109
	}
110 110
}
117 117
		Path:     "/",
118 118
		HttpOnly: true,
119 119
		Secure:   s.CookieSecure,
120 -
		SameSite: http.SameSiteLaxMode,
120 +
		SameSite: http.SameSiteStrictMode,
121 121
		MaxAge:   -1,
122 122
	}
123 123
}
176 176
	return SecureEqual(input, expected)
177 177
}
178 178
179 -
// SecureEqual reports whether a and b are equal in constant time.
179 +
// SecureEqual reports whether a and b are equal in constant time. Inputs are
180 +
// padded/truncated to a fixed 256-byte buffer so length differences don't leak
181 +
// via timing. A length-equal mask is AND-ed with the buffer compare.
180 182
func SecureEqual(a, b string) bool {
181 -
	return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
183 +
	const padLen = 256
184 +
	var bufA, bufB [padLen]byte
185 +
	ab := []byte(a)
186 +
	bb := []byte(b)
187 +
	na := min(len(ab), padLen)
188 +
	nb := min(len(bb), padLen)
189 +
	copy(bufA[:na], ab[:na])
190 +
	copy(bufB[:nb], bb[:nb])
191 +
	lengthsMatch := subtle.ConstantTimeEq(int32(len(ab)), int32(len(bb)))
192 +
	bytesMatch := subtle.ConstantTimeCompare(bufA[:], bufB[:])
193 +
	return (lengthsMatch & bytesMatch) == 1
182 194
}
183 195
184 196
// GenerateSessionToken returns a 32-byte random hex token.
crates-go/auth/auth_test.go (added) +177 −0
1 +
package auth
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strings"
6 +
	"testing"
7 +
8 +
	"golang.org/x/crypto/bcrypt"
9 +
)
10 +
11 +
func TestSecureEqual_Equal(t *testing.T) {
12 +
	if !SecureEqual("hunter2", "hunter2") {
13 +
		t.Fatal("equal strings should match")
14 +
	}
15 +
}
16 +
17 +
func TestSecureEqual_Unequal(t *testing.T) {
18 +
	if SecureEqual("hunter2", "hunter3") {
19 +
		t.Fatal("different strings should not match")
20 +
	}
21 +
}
22 +
23 +
func TestSecureEqual_BothEmpty(t *testing.T) {
24 +
	if !SecureEqual("", "") {
25 +
		t.Fatal("two empty strings should match")
26 +
	}
27 +
}
28 +
29 +
func TestSecureEqual_EmptyVsNonempty(t *testing.T) {
30 +
	if SecureEqual("", "x") {
31 +
		t.Fatal("empty vs nonempty should not match")
32 +
	}
33 +
	if SecureEqual("x", "") {
34 +
		t.Fatal("nonempty vs empty should not match")
35 +
	}
36 +
}
37 +
38 +
func TestSecureEqual_LengthMismatch(t *testing.T) {
39 +
	if SecureEqual("short", "longer_password") {
40 +
		t.Fatal("length mismatch should not match")
41 +
	}
42 +
}
43 +
44 +
func TestSecureEqual_Over256SameLengthAndPrefix(t *testing.T) {
45 +
	a := strings.Repeat("a", 300)
46 +
	b := strings.Repeat("a", 256) + strings.Repeat("b", 44)
47 +
	if !SecureEqual(a, b) {
48 +
		t.Fatal("same length, identical first 256 bytes should match after pad/truncate")
49 +
	}
50 +
}
51 +
52 +
func TestSecureEqual_Over256DifferentPrefix(t *testing.T) {
53 +
	a := strings.Repeat("a", 300)
54 +
	b := "z" + strings.Repeat("a", 299)
55 +
	if SecureEqual(a, b) {
56 +
		t.Fatal("differing prefix within first 256 bytes should not match")
57 +
	}
58 +
}
59 +
60 +
func TestSecureEqual_Exactly256(t *testing.T) {
61 +
	pw := strings.Repeat("x", 256)
62 +
	if !SecureEqual(pw, pw) {
63 +
		t.Fatal("exact 256-byte identical strings should match")
64 +
	}
65 +
}
66 +
67 +
func TestVerifyPassword_PlainHappy(t *testing.T) {
68 +
	if !VerifyPassword("hunter2", "hunter2") {
69 +
		t.Fatal("plain password should verify")
70 +
	}
71 +
}
72 +
73 +
func TestVerifyPassword_PlainSad(t *testing.T) {
74 +
	if VerifyPassword("hunter2", "hunter3") {
75 +
		t.Fatal("wrong plain password should fail")
76 +
	}
77 +
}
78 +
79 +
func TestVerifyPassword_PlainLengthMismatch(t *testing.T) {
80 +
	if VerifyPassword("short", "longer_password") {
81 +
		t.Fatal("length mismatch should fail")
82 +
	}
83 +
}
84 +
85 +
func TestVerifyPassword_BcryptHappy(t *testing.T) {
86 +
	hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
87 +
	if err != nil {
88 +
		t.Fatal(err)
89 +
	}
90 +
	if !VerifyPassword("hunter2", string(hash)) {
91 +
		t.Fatal("bcrypt password should verify")
92 +
	}
93 +
}
94 +
95 +
func TestVerifyPassword_BcryptSad(t *testing.T) {
96 +
	hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
97 +
	if err != nil {
98 +
		t.Fatal(err)
99 +
	}
100 +
	if VerifyPassword("nope", string(hash)) {
101 +
		t.Fatal("wrong bcrypt password should fail")
102 +
	}
103 +
}
104 +
105 +
func TestGenerateSessionToken(t *testing.T) {
106 +
	tok, err := GenerateSessionToken()
107 +
	if err != nil {
108 +
		t.Fatal(err)
109 +
	}
110 +
	if len(tok) != 64 {
111 +
		t.Fatalf("want 64 hex chars, got %d", len(tok))
112 +
	}
113 +
	for _, c := range tok {
114 +
		isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
115 +
		if !isHex {
116 +
			t.Fatalf("non-hex char %q in token", c)
117 +
		}
118 +
	}
119 +
	tok2, _ := GenerateSessionToken()
120 +
	if tok == tok2 {
121 +
		t.Fatal("two tokens should differ")
122 +
	}
123 +
}
124 +
125 +
func TestSessionCookie_Attrs(t *testing.T) {
126 +
	s := &Store{CookieName: "session", CookieSecure: false}
127 +
	c := s.SessionCookie("abc123")
128 +
	if c.Name != "session" || c.Value != "abc123" {
129 +
		t.Fatalf("bad name/value: %+v", c)
130 +
	}
131 +
	if !c.HttpOnly {
132 +
		t.Fatal("HttpOnly should be set")
133 +
	}
134 +
	if c.SameSite != http.SameSiteStrictMode {
135 +
		t.Fatalf("want SameSite=Strict, got %v", c.SameSite)
136 +
	}
137 +
	if c.Path != "/" {
138 +
		t.Fatalf("want Path=/, got %q", c.Path)
139 +
	}
140 +
	if c.MaxAge != 7*24*3600 {
141 +
		t.Fatalf("want MaxAge=604800, got %d", c.MaxAge)
142 +
	}
143 +
	if c.Secure {
144 +
		t.Fatal("Secure should be false")
145 +
	}
146 +
}
147 +
148 +
func TestSessionCookie_Secure(t *testing.T) {
149 +
	s := &Store{CookieName: "session", CookieSecure: true}
150 +
	if !s.SessionCookie("x").Secure {
151 +
		t.Fatal("Secure should be true when CookieSecure=true")
152 +
	}
153 +
}
154 +
155 +
func TestClearCookie(t *testing.T) {
156 +
	s := &Store{CookieName: "session"}
157 +
	c := s.ClearCookie()
158 +
	if c.Value != "" || c.MaxAge != -1 {
159 +
		t.Fatalf("clear cookie should have empty value and MaxAge=-1, got %+v", c)
160 +
	}
161 +
	if c.SameSite != http.SameSiteStrictMode {
162 +
		t.Fatalf("want SameSite=Strict, got %v", c.SameSite)
163 +
	}
164 +
}
165 +
166 +
func TestGenerateShortID(t *testing.T) {
167 +
	id, err := GenerateShortID(10)
168 +
	if err != nil {
169 +
		t.Fatal(err)
170 +
	}
171 +
	if len(id) != 10 {
172 +
		t.Fatalf("want len 10, got %d", len(id))
173 +
	}
174 +
	if _, err := GenerateShortID(0); err == nil {
175 +
		t.Fatal("zero length should error")
176 +
	}
177 +
}
crates-go/tui/commands.go (added) +45 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
	"os/exec"
6 +
	"runtime"
7 +
8 +
	tea "charm.land/bubbletea/v2"
9 +
	"github.com/atotto/clipboard"
10 +
)
11 +
12 +
// CopyToClipboardCmd copies text to the OS clipboard and emits a StatusMsg.
13 +
func CopyToClipboardCmd(text, okStatus string) tea.Cmd {
14 +
	return func() tea.Msg {
15 +
		if err := clipboard.WriteAll(text); err != nil {
16 +
			return StatusMsg{Text: "clipboard: " + err.Error()}
17 +
		}
18 +
		return StatusMsg{Text: okStatus, OK: true}
19 +
	}
20 +
}
21 +
22 +
// OpenURLCmd opens url in the default browser and emits a StatusMsg.
23 +
func OpenURLCmd(url string) tea.Cmd {
24 +
	return func() tea.Msg {
25 +
		if err := openURL(url); err != nil {
26 +
			return StatusMsg{Text: "open: " + err.Error()}
27 +
		}
28 +
		return StatusMsg{Text: "opened " + url, OK: true}
29 +
	}
30 +
}
31 +
32 +
func openURL(url string) error {
33 +
	var cmd *exec.Cmd
34 +
	switch runtime.GOOS {
35 +
	case "linux":
36 +
		cmd = exec.Command("xdg-open", url)
37 +
	case "darwin":
38 +
		cmd = exec.Command("open", url)
39 +
	case "windows":
40 +
		cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
41 +
	default:
42 +
		return fmt.Errorf("unsupported platform %s", runtime.GOOS)
43 +
	}
44 +
	return cmd.Start()
45 +
}
crates-go/tui/config.go (added) +60 −0
1 +
package tui
2 +
3 +
import (
4 +
	"os"
5 +
	"path/filepath"
6 +
7 +
	"github.com/BurntSushi/toml"
8 +
)
9 +
10 +
// Config is the on-disk TUI config shape shared across apps.
11 +
type Config struct {
12 +
	RemoteURL string `toml:"remote_url"`
13 +
	APIKey    string `toml:"api_key"`
14 +
}
15 +
16 +
// ConfigPath returns $XDG_CONFIG_HOME/<app>/config.toml.
17 +
func ConfigPath(app string) (string, error) {
18 +
	dir, err := os.UserConfigDir()
19 +
	if err != nil {
20 +
		return "", err
21 +
	}
22 +
	return filepath.Join(dir, app, "config.toml"), nil
23 +
}
24 +
25 +
// LoadConfig reads the named app's config. Missing file returns a zero Config.
26 +
func LoadConfig(app string) (Config, error) {
27 +
	var cfg Config
28 +
	path, err := ConfigPath(app)
29 +
	if err != nil {
30 +
		return cfg, err
31 +
	}
32 +
	data, err := os.ReadFile(path)
33 +
	if err != nil {
34 +
		if os.IsNotExist(err) {
35 +
			return cfg, nil
36 +
		}
37 +
		return cfg, err
38 +
	}
39 +
	if err := toml.Unmarshal(data, &cfg); err != nil {
40 +
		return cfg, err
41 +
	}
42 +
	return cfg, nil
43 +
}
44 +
45 +
// SaveConfig writes cfg as TOML, creating parent dirs as needed.
46 +
func SaveConfig(app string, cfg Config) error {
47 +
	path, err := ConfigPath(app)
48 +
	if err != nil {
49 +
		return err
50 +
	}
51 +
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
52 +
		return err
53 +
	}
54 +
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
55 +
	if err != nil {
56 +
		return err
57 +
	}
58 +
	defer f.Close()
59 +
	return toml.NewEncoder(f).Encode(cfg)
60 +
}
crates-go/tui/editor.go (added) +53 −0
1 +
package tui
2 +
3 +
import (
4 +
	"os"
5 +
	"os/exec"
6 +
7 +
	tea "charm.land/bubbletea/v2"
8 +
)
9 +
10 +
// SpawnEditor opens the user's $EDITOR on a temp file seeded with content.
11 +
// pattern is the os.CreateTemp pattern (e.g. "jotts-*.md"); empty falls back
12 +
// to a generic ".txt" pattern. tag is echoed in the resulting message so
13 +
// callers can correlate the result with the record being edited.
14 +
func SpawnEditor(tag, pattern, content string) tea.Cmd {
15 +
	editor := os.Getenv("EDITOR")
16 +
	if editor == "" {
17 +
		return func() tea.Msg {
18 +
			return StatusMsg{Text: "$EDITOR not set"}
19 +
		}
20 +
	}
21 +
	if pattern == "" {
22 +
		pattern = "editor-*.txt"
23 +
	}
24 +
25 +
	tmp, err := os.CreateTemp("", pattern)
26 +
	if err != nil {
27 +
		return func() tea.Msg {
28 +
			return StatusMsg{Text: "tempfile: " + err.Error()}
29 +
		}
30 +
	}
31 +
	path := tmp.Name()
32 +
	if _, err := tmp.WriteString(content); err != nil {
33 +
		_ = tmp.Close()
34 +
		_ = os.Remove(path)
35 +
		return func() tea.Msg {
36 +
			return StatusMsg{Text: "tempfile: " + err.Error()}
37 +
		}
38 +
	}
39 +
	_ = tmp.Close()
40 +
41 +
	cmd := exec.Command(editor, path)
42 +
	return tea.ExecProcess(cmd, func(err error) tea.Msg {
43 +
		defer os.Remove(path)
44 +
		if err != nil {
45 +
			return EditorFinishedMsg{Tag: tag, Err: err}
46 +
		}
47 +
		b, rerr := os.ReadFile(path)
48 +
		if rerr != nil {
49 +
			return EditorFinishedMsg{Tag: tag, Err: rerr}
50 +
		}
51 +
		return EditorFinishedMsg{Tag: tag, Content: string(b)}
52 +
	})
53 +
}
crates-go/tui/go.mod (added) +30 −0
1 +
module github.com/stevedylandev/andromeda/crates-go/tui
2 +
3 +
go 1.25.0
4 +
5 +
require (
6 +
	charm.land/bubbles/v2 v2.1.0
7 +
	charm.land/bubbletea/v2 v2.0.6
8 +
	charm.land/lipgloss/v2 v2.0.3
9 +
	github.com/BurntSushi/toml v1.6.0
10 +
	github.com/atotto/clipboard v0.1.4
11 +
)
12 +
13 +
require (
14 +
	github.com/charmbracelet/colorprofile v0.4.3 // indirect
15 +
	github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
16 +
	github.com/charmbracelet/x/ansi v0.11.7 // indirect
17 +
	github.com/charmbracelet/x/term v0.2.2 // indirect
18 +
	github.com/charmbracelet/x/termios v0.1.1 // indirect
19 +
	github.com/charmbracelet/x/windows v0.2.2 // indirect
20 +
	github.com/clipperhouse/displaywidth v0.11.0 // indirect
21 +
	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
22 +
	github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
23 +
	github.com/mattn/go-runewidth v0.0.23 // indirect
24 +
	github.com/muesli/cancelreader v0.2.2 // indirect
25 +
	github.com/rivo/uniseg v0.4.7 // indirect
26 +
	github.com/sahilm/fuzzy v0.1.1 // indirect
27 +
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
28 +
	golang.org/x/sync v0.20.0 // indirect
29 +
	golang.org/x/sys v0.43.0 // indirect
30 +
)
crates-go/tui/go.sum (added) +50 −0
1 +
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
2 +
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
3 +
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
4 +
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
5 +
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
6 +
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
7 +
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
8 +
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
9 +
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
10 +
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
11 +
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
12 +
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
13 +
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
14 +
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
15 +
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
16 +
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
17 +
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
18 +
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
19 +
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
20 +
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
21 +
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
22 +
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
23 +
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
24 +
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
25 +
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
26 +
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
27 +
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
28 +
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
29 +
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
30 +
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
31 +
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
32 +
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
33 +
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
34 +
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
35 +
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
36 +
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
37 +
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
38 +
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
39 +
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
40 +
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
41 +
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
42 +
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
43 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
44 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
45 +
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
46 +
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
47 +
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
48 +
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
49 +
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
50 +
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
crates-go/tui/keys.go (added) +57 −0
1 +
// Package tui holds bubbletea helpers shared by andromeda Go apps.
2 +
package tui
3 +
4 +
import "charm.land/bubbles/v2/key"
5 +
6 +
// KeyMap is the default keybinding set used by the andromeda Go TUIs.
7 +
type KeyMap struct {
8 +
	Open        key.Binding
9 +
	Back        key.Binding
10 +
	Quit        key.Binding
11 +
	Create      key.Binding
12 +
	Edit        key.Binding
13 +
	ExtEdit     key.Binding
14 +
	Delete      key.Binding
15 +
	Copy        key.Binding
16 +
	CopyLink    key.Binding
17 +
	OpenBrowser key.Binding
18 +
	Refresh     key.Binding
19 +
	ToggleWrap  key.Binding
20 +
	Help        key.Binding
21 +
	ScrollUp    key.Binding
22 +
	ScrollDown  key.Binding
23 +
}
24 +
25 +
// DefaultKeys returns the canonical key map shared across apps.
26 +
func DefaultKeys() KeyMap {
27 +
	return KeyMap{
28 +
		Open:        key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")),
29 +
		Back:        key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h/esc", "back")),
30 +
		Quit:        key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
31 +
		Create:      key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
32 +
		Edit:        key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
33 +
		ExtEdit:     key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")),
34 +
		Delete:      key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
35 +
		Copy:        key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")),
36 +
		CopyLink:    key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")),
37 +
		OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")),
38 +
		Refresh:     key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
39 +
		ToggleWrap:  key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")),
40 +
		Help:        key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
41 +
		ScrollUp:    key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
42 +
		ScrollDown:  key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
43 +
	}
44 +
}
45 +
46 +
func (k KeyMap) ShortHelp() []key.Binding {
47 +
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Help, k.Quit}
48 +
}
49 +
50 +
func (k KeyMap) FullHelp() [][]key.Binding {
51 +
	return [][]key.Binding{
52 +
		{k.Open, k.Back, k.Create, k.Edit},
53 +
		{k.ExtEdit, k.Delete, k.Copy, k.CopyLink},
54 +
		{k.OpenBrowser, k.Refresh, k.ToggleWrap, k.Help},
55 +
		{k.ScrollUp, k.ScrollDown, k.Quit},
56 +
	}
57 +
}
crates-go/tui/list.go (added) +37 −0
1 +
package tui
2 +
3 +
import (
4 +
	"charm.land/bubbles/v2/list"
5 +
	"charm.land/lipgloss/v2"
6 +
)
7 +
8 +
// ListIDStyle dims a trailing short-id segment in list item titles.
9 +
var ListIDStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
10 +
11 +
// ANSIListDelegate returns the andromeda default list delegate (no description,
12 +
// no spacing, accent color "3").
13 +
func ANSIListDelegate() list.DefaultDelegate {
14 +
	d := list.NewDefaultDelegate()
15 +
	d.ShowDescription = false
16 +
	d.SetSpacing(0)
17 +
	d.Styles.NormalTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Padding(0, 0, 0, 2)
18 +
	d.Styles.SelectedTitle = lipgloss.NewStyle().
19 +
		Foreground(lipgloss.Color("3")).
20 +
		Bold(true).
21 +
		Border(lipgloss.NormalBorder(), false, false, false, true).
22 +
		BorderForeground(lipgloss.Color("3")).
23 +
		Padding(0, 0, 0, 1)
24 +
	d.Styles.DimmedTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
25 +
	d.Styles.FilterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
26 +
	return d
27 +
}
28 +
29 +
// ANSIListStyles returns the andromeda default list.Styles.
30 +
func ANSIListStyles() list.Styles {
31 +
	s := list.DefaultStyles(true)
32 +
	s.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")).Padding(0, 1)
33 +
	s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 0)
34 +
	s.NoItems = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2)
35 +
	s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3"))
36 +
	return s
37 +
}
crates-go/tui/messages.go (added) +19 −0
1 +
package tui
2 +
3 +
// StatusMsg is a transient toast-style status surfaced by Update.
4 +
type StatusMsg struct {
5 +
	Text string
6 +
	OK   bool
7 +
}
8 +
9 +
// ClearStatusMsg removes the active status message.
10 +
type ClearStatusMsg struct{}
11 +
12 +
// EditorFinishedMsg is delivered after an external $EDITOR session ends.
13 +
// Tag is an opaque identifier supplied by the caller (commonly a record's
14 +
// short id) so the receiver can correlate the result.
15 +
type EditorFinishedMsg struct {
16 +
	Tag     string
17 +
	Content string
18 +
	Err     error
19 +
}
crates-go/tui/styles.go (added) +41 −0
1 +
package tui
2 +
3 +
import "charm.land/lipgloss/v2"
4 +
5 +
// Standard styles shared across andromeda TUIs.
6 +
var (
7 +
	TitleStyle = lipgloss.NewStyle().
8 +
			Bold(true).
9 +
			Foreground(lipgloss.Color("3")).
10 +
			Padding(0, 1)
11 +
	StatusOKStyle = lipgloss.NewStyle().
12 +
			Foreground(lipgloss.Color("2")).
13 +
			Bold(true)
14 +
	StatusErrStyle = lipgloss.NewStyle().
15 +
			Foreground(lipgloss.Color("1")).
16 +
			Bold(true)
17 +
	HintStyle = lipgloss.NewStyle().
18 +
			Foreground(lipgloss.Color("8"))
19 +
	ModalStyle = lipgloss.NewStyle().
20 +
			Border(lipgloss.RoundedBorder()).
21 +
			BorderForeground(lipgloss.Color("3")).
22 +
			Padding(1, 2)
23 +
	StatusModalStyle = lipgloss.NewStyle().
24 +
				Border(lipgloss.RoundedBorder()).
25 +
				BorderForeground(lipgloss.Color("3")).
26 +
				Padding(0, 1)
27 +
)
28 +
29 +
// Border returns the inactive pane border style using the given border.
30 +
func Border(b lipgloss.Border) lipgloss.Style {
31 +
	return lipgloss.NewStyle().
32 +
		Border(b).
33 +
		BorderForeground(lipgloss.Color("8"))
34 +
}
35 +
36 +
// BorderActive returns the focused pane border style using the given border.
37 +
func BorderActive(b lipgloss.Border) lipgloss.Style {
38 +
	return lipgloss.NewStyle().
39 +
		Border(b).
40 +
		BorderForeground(lipgloss.Color("3"))
41 +
}