chore: refactored shared tui apps to use tui pkg
57e6e58e
33 file(s) · +744 −545
| 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 |
|
| 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 | ||
| 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) } |
| 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 | } |
| 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 { |
| 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() } |
| 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) |
|
| 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 | + | ) |
| 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): |
|
| 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 |
|
| 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 | ) |
|
| 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= |
| 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 | ||
| 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) } |
| 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 | } |
|
| 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 | } |
| 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 { |
| 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() } |
| 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) |
|
| 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 | + | ) |
| 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): |
|
| 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 |
|
| 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. |
|
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |