From f49d5a1e6a411bb682bcc729871e1a281bf5f0a2 Mon Sep 17 00:00:00 2001 From: Mesha Edwardson Date: Tue, 17 Feb 2026 21:23:09 -0800 Subject: [PATCH 1/4] feat: initial attempt to add query saving --- cmd/root.go | 8 +- internal/storage/doc.go | 8 ++ internal/storage/queries.go | 122 ++++++++++++++++++++++ internal/storage/queries_test.go | 135 ++++++++++++++++++++++++ internal/tui/model.go | 27 ++++- internal/tui/screen_editor.go | 28 ++++- internal/tui/screen_home.go | 15 ++- internal/tui/screen_query.go | 4 +- internal/tui/screen_save.go | 92 +++++++++++++++++ internal/tui/screen_saved.go | 171 +++++++++++++++++++++++++++++++ 10 files changed, 600 insertions(+), 10 deletions(-) create mode 100644 internal/storage/doc.go create mode 100644 internal/storage/queries.go create mode 100644 internal/storage/queries_test.go create mode 100644 internal/tui/screen_save.go create mode 100644 internal/tui/screen_saved.go diff --git a/cmd/root.go b/cmd/root.go index ec1fccf..0c9ff0f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/viper" "github.com/ixti/apiska/internal/rds" + "github.com/ixti/apiska/internal/storage" "github.com/ixti/apiska/internal/tui" ) @@ -66,7 +67,12 @@ func run(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to initialize AWS RDS client: %w", err) } - p := tui.NewProgram(client) + store, err := storage.NewStore() + if err != nil { + return fmt.Errorf("failed to initialize saved queries store: %w", err) + } + + p := tui.NewProgram(client, store) if _, err := p.Run(); err != nil { fmt.Printf("unexpected apiska error: %v", err) os.Exit(1) diff --git a/internal/storage/doc.go b/internal/storage/doc.go new file mode 100644 index 0000000..df07247 --- /dev/null +++ b/internal/storage/doc.go @@ -0,0 +1,8 @@ +// Copyright (C) 2026 Alexey Zapparov +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package storage handles persistence of user data -- your personal crate of +// vinyl that survives the gig. Saved queries get pressed to disk and stay there +// until you're ready to spin them again. Everything lives in ~/.apiska/ so your +// setlists follow you wherever you go. +package storage diff --git a/internal/storage/queries.go b/internal/storage/queries.go new file mode 100644 index 0000000..168b817 --- /dev/null +++ b/internal/storage/queries.go @@ -0,0 +1,122 @@ +// Copyright (C) 2026 Alexey Zapparov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package storage + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/ixti/apiska/internal/exporters" +) + +// SavedQuery represents a query saved for later use -- a track in your crate. +type SavedQuery struct { + ID string `json:"id"` + Name string `json:"name"` + SQL string `json:"sql"` + CreatedAt time.Time `json:"created_at"` +} + +// queriesFile is the JSON structure for persisting queries to disk. +type queriesFile struct { + Queries []SavedQuery `json:"queries"` +} + +// Store manages saved queries persistence -- the record store that never closes. +type Store struct { + mu sync.RWMutex + path string + queries []SavedQuery +} + +// NewStore creates a new Store loading from ~/.apiska/queries.json. +// If the file doesn't exist, starts with an empty collection. +func NewStore() (*Store, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + + path := filepath.Join(home, ".apiska", "queries.json") + s := &Store{ + path: path, + queries: []SavedQuery{}, + } + + if err := s.load(); err != nil && !os.IsNotExist(err) { + return nil, err + } + + return s, nil +} + +// Save stores a query with the given name -- pressing a new record. +func (s *Store) Save(name, sql string) error { + s.mu.Lock() + defer s.mu.Unlock() + + query := SavedQuery{ + ID: fmt.Sprintf("q-%d", time.Now().UnixNano()), + Name: name, + SQL: sql, + CreatedAt: time.Now(), + } + + s.queries = append([]SavedQuery{query}, s.queries...) + return s.persist() +} + +// List returns all saved queries -- browsing the crate. +func (s *Store) List() []SavedQuery { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]SavedQuery, len(s.queries)) + copy(result, s.queries) + return result +} + +// Delete removes a saved query by ID -- pulling a record from the crate. +func (s *Store) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + for i, q := range s.queries { + if q.ID == id { + s.queries = append(s.queries[:i], s.queries[i+1:]...) + return s.persist() + } + } + return nil +} + +// load reads queries from disk -- opening the crate. +func (s *Store) load() error { + data, err := os.ReadFile(s.path) + if err != nil { + return err + } + + var file queriesFile + if err := json.Unmarshal(data, &file); err != nil { + return fmt.Errorf("failed to parse queries file: %w", err) + } + + s.queries = file.Queries + return nil +} + +// persist writes queries to disk -- closing the crate for the night. +func (s *Store) persist() error { + file := queriesFile{Queries: s.queries} + _, err := exporters.WriteJson(s.path, func(enc *json.Encoder) error { + enc.SetIndent("", " ") + return enc.Encode(file) + }) + return err +} diff --git a/internal/storage/queries_test.go b/internal/storage/queries_test.go new file mode 100644 index 0000000..4b154af --- /dev/null +++ b/internal/storage/queries_test.go @@ -0,0 +1,135 @@ +// Copyright (C) 2026 Alexey Zapparov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package storage + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStore_SaveAndList(t *testing.T) { + // Create temp directory for test + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "queries.json") + + store := &Store{path: path, queries: []SavedQuery{}} + + // Save a query + err := store.Save("Test Query", "SELECT * FROM users") + require.NoError(t, err) + + // List should return the saved query + queries := store.List() + require.Len(t, queries, 1) + assert.Equal(t, "Test Query", queries[0].Name) + assert.Equal(t, "SELECT * FROM users", queries[0].SQL) + assert.NotEmpty(t, queries[0].ID) + assert.False(t, queries[0].CreatedAt.IsZero()) +} + +func TestStore_SaveMultiple(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "queries.json") + + store := &Store{path: path, queries: []SavedQuery{}} + + err := store.Save("Query 1", "SELECT 1") + require.NoError(t, err) + + err = store.Save("Query 2", "SELECT 2") + require.NoError(t, err) + + queries := store.List() + require.Len(t, queries, 2) + + // Newest should be first + assert.Equal(t, "Query 2", queries[0].Name) + assert.Equal(t, "Query 1", queries[1].Name) +} + +func TestStore_Delete(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "queries.json") + + store := &Store{path: path, queries: []SavedQuery{}} + + err := store.Save("Query 1", "SELECT 1") + require.NoError(t, err) + + err = store.Save("Query 2", "SELECT 2") + require.NoError(t, err) + + queries := store.List() + require.Len(t, queries, 2) + + // Delete first query + err = store.Delete(queries[0].ID) + require.NoError(t, err) + + queries = store.List() + require.Len(t, queries, 1) + assert.Equal(t, "Query 1", queries[0].Name) +} + +func TestStore_DeleteNonExistent(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "queries.json") + + store := &Store{path: path, queries: []SavedQuery{}} + + // Deleting non-existent ID should not error + err := store.Delete("nonexistent-id") + require.NoError(t, err) +} + +func TestStore_Persistence(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "queries.json") + + // Create first store and save + store1 := &Store{path: path, queries: []SavedQuery{}} + err := store1.Save("Persisted Query", "SELECT * FROM orders") + require.NoError(t, err) + + // Create second store and load from same file + store2 := &Store{path: path, queries: []SavedQuery{}} + err = store2.load() + require.NoError(t, err) + + queries := store2.List() + require.Len(t, queries, 1) + assert.Equal(t, "Persisted Query", queries[0].Name) + assert.Equal(t, "SELECT * FROM orders", queries[0].SQL) +} + +func TestStore_LoadNonExistent(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "nonexistent.json") + + store := &Store{path: path, queries: []SavedQuery{}} + err := store.load() + + assert.True(t, os.IsNotExist(err)) +} + +func TestStore_ListReturnsDefensiveCopy(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "queries.json") + + store := &Store{path: path, queries: []SavedQuery{}} + err := store.Save("Original", "SELECT 1") + require.NoError(t, err) + + // Modify the returned slice + queries := store.List() + queries[0].Name = "Modified" + + // Original should be unchanged + queries2 := store.List() + assert.Equal(t, "Original", queries2[0].Name) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 53208f3..a70bdf1 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/ixti/apiska/internal/launchers" "github.com/ixti/apiska/internal/rds" + "github.com/ixti/apiska/internal/storage" "github.com/ixti/apiska/internal/tui/styles" ) @@ -58,9 +59,20 @@ type submitQueryMsg struct { screen Screen } +// loadSavedQueryMsg is sent when a saved query should be loaded into a new editor. +type loadSavedQueryMsg struct { + sql string +} + +// querySavedMsg is sent when a query has been saved successfully. +type querySavedMsg struct { + name string +} + // model is the root model that manages the screen stack and chrome. type model struct { client *rds.Client + store *storage.Store screens []Screen @@ -154,6 +166,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.screens[len(m.screens)-1] = updated.(Screen) } return m, msg.screen.Init() + + case loadSavedQueryMsg: + // Push editor with SQL (keep saved queries screen in stack for back navigation) + screen := newEditorScreen(m.client, m.store, msg.sql) + m.screens = append(m.screens, screen) + if m.width > 0 && m.height > 0 { + updated, _ := screen.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.screens[len(m.screens)-1] = updated.(Screen) + } + return m, screen.Init() } // Delegate to the current screen @@ -210,10 +232,11 @@ func (m model) ContentSize() (width, height int) { return m.width, m.height - 1 } -func NewProgram(client *rds.Client) *tea.Program { +func NewProgram(client *rds.Client, store *storage.Store) *tea.Program { return tea.NewProgram(model{ client: client, - screens: []Screen{newHomeScreen(client)}, + store: store, + screens: []Screen{newHomeScreen(client, store)}, }) } diff --git a/internal/tui/screen_editor.go b/internal/tui/screen_editor.go index 5ef261a..51ca552 100644 --- a/internal/tui/screen_editor.go +++ b/internal/tui/screen_editor.go @@ -11,18 +11,21 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/ixti/apiska/internal/launchers" "github.com/ixti/apiska/internal/rds" + "github.com/ixti/apiska/internal/storage" "github.com/ixti/apiska/internal/tui/styles" ) type editorScreen struct { client *rds.Client + store *storage.Store textarea textarea.Model err error + saveMsg string width, height int } -func newEditorScreen(client *rds.Client, initialSQL string) *editorScreen { +func newEditorScreen(client *rds.Client, store *storage.Store, initialSQL string) *editorScreen { ta := textarea.New() ta.Placeholder = "SELECT * FROM ..." ta.SetValue(initialSQL) @@ -37,6 +40,7 @@ func newEditorScreen(client *rds.Client, initialSQL string) *editorScreen { return &editorScreen{ client: client, + store: store, textarea: ta, } } @@ -47,6 +51,7 @@ func (s *editorScreen) Title() string { func (s *editorScreen) KeyHints() string { return formatHint("F5/ctrl+r", "execute") + + styles.HintSep.String() + formatHint("ctrl+s", "save") + styles.HintSep.String() + formatHint("ctrl+e", "external editor") } @@ -62,11 +67,22 @@ func (s *editorScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.updateTextareaSize() case tea.KeyMsg: + // Clear any status message when typing + s.saveMsg = "" + switch msg.Type { case tea.KeyF5, tea.KeyCtrlR: return s, s.executeQuery() case tea.KeyCtrlE: return s, s.openExternalEditor() + case tea.KeyCtrlS: + sql := strings.TrimSpace(s.textarea.Value()) + if sql == "" { + return s, nil + } + return s, func() tea.Msg { + return PushScreenMsg{Screen: newSaveScreen(s.store, sql, s.width)} + } } case editorFinishedMsg: @@ -93,6 +109,10 @@ func (s *editorScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, nil } return s, nil + + case querySavedMsg: + s.saveMsg = "Saved: " + msg.name + return s, nil } var cmd tea.Cmd @@ -106,6 +126,9 @@ func (s *editorScreen) View() string { if s.err != nil { errStyle := lipgloss.NewStyle().Foreground(styles.Red) content = s.textarea.View() + "\n" + errStyle.Render("Error: "+s.err.Error()) + } else if s.saveMsg != "" { + msgStyle := lipgloss.NewStyle().Foreground(styles.Green) + content = s.textarea.View() + "\n" + msgStyle.Render(s.saveMsg) } else { content = s.textarea.View() } @@ -135,6 +158,7 @@ func (s *editorScreen) executeQuery() tea.Cmd { // Create query screen that will execute in background queryScreen := newQueryScreen(query) queryScreen.client = s.client + queryScreen.store = s.store queryScreen.executing = true return func() tea.Msg { @@ -154,5 +178,3 @@ func (s *editorScreen) openExternalEditor() tea.Cmd { return editorFinishedMsg{editor: editor, err: err} }) } - - diff --git a/internal/tui/screen_home.go b/internal/tui/screen_home.go index 4a08ab3..1e2998a 100644 --- a/internal/tui/screen_home.go +++ b/internal/tui/screen_home.go @@ -8,18 +8,20 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/ixti/apiska/internal/rds" + "github.com/ixti/apiska/internal/storage" "github.com/ixti/apiska/internal/tui/styles" ) type homeScreen struct { client *rds.Client + store *storage.Store queries []*rds.Query table table.Model width, height int } -func newHomeScreen(client *rds.Client) *homeScreen { +func newHomeScreen(client *rds.Client, store *storage.Store) *homeScreen { columns := []table.Column{ {Title: "Label", Width: 20}, {Title: "Time", Width: 20}, @@ -33,6 +35,7 @@ func newHomeScreen(client *rds.Client) *homeScreen { return &homeScreen{ client: client, + store: store, queries: []*rds.Query{}, table: t, } @@ -43,7 +46,7 @@ func (s *homeScreen) Title() string { } func (s *homeScreen) KeyHints() string { - hints := formatHint("ctrl+n", "new query") + hints := formatHint("ctrl+n", "new query") + styles.HintSep.String() + formatHint("ctrl+l", "saved") if len(s.queries) > 0 { hints = formatHint("↑↓", "navigate") + @@ -71,6 +74,7 @@ func (s *homeScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if q := s.selectedQuery(); q != nil { screen := newQueryScreen(q) screen.client = s.client + screen.store = s.store return s, func() tea.Msg { return PushScreenMsg{Screen: screen} } @@ -79,7 +83,12 @@ func (s *homeScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyCtrlN: return s, func() tea.Msg { - return PushScreenMsg{Screen: newEditorScreen(s.client, "")} + return PushScreenMsg{Screen: newEditorScreen(s.client, s.store, "")} + } + + case tea.KeyCtrlL: + return s, func() tea.Msg { + return PushScreenMsg{Screen: newSavedScreen(s.store)} } } diff --git a/internal/tui/screen_query.go b/internal/tui/screen_query.go index d8aa0cf..501da84 100644 --- a/internal/tui/screen_query.go +++ b/internal/tui/screen_query.go @@ -17,6 +17,7 @@ import ( "github.com/ixti/apiska/internal/formatters" "github.com/ixti/apiska/internal/launchers" "github.com/ixti/apiska/internal/rds" + "github.com/ixti/apiska/internal/storage" "github.com/ixti/apiska/internal/tui/styles" ) @@ -33,6 +34,7 @@ type csvExportedMsg struct { type queryScreen struct { client *rds.Client + store *storage.Store query *rds.Query table table.Model executing bool @@ -105,7 +107,7 @@ func (s *queryScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { initialSQL := s.query.SQL return s, tea.Sequence( func() tea.Msg { return PopScreenMsg{} }, - func() tea.Msg { return PushScreenMsg{Screen: newEditorScreen(s.client, initialSQL)} }, + func() tea.Msg { return PushScreenMsg{Screen: newEditorScreen(s.client, s.store, initialSQL)} }, ) case tea.KeyCtrlE: // Export as CSV diff --git a/internal/tui/screen_save.go b/internal/tui/screen_save.go new file mode 100644 index 0000000..290b940 --- /dev/null +++ b/internal/tui/screen_save.go @@ -0,0 +1,92 @@ +// Copyright (C) 2026 Alexey Zapparov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ixti/apiska/internal/storage" + "github.com/ixti/apiska/internal/tui/styles" +) + +type saveScreen struct { + store *storage.Store + sql string + input textinput.Model + + width, height int +} + +func newSaveScreen(store *storage.Store, sql string, width int) *saveScreen { + input := textinput.New() + input.Placeholder = "My query" + input.Width = width - 20 + input.TextStyle = lipgloss.NewStyle().Foreground(styles.Text) + input.PlaceholderStyle = lipgloss.NewStyle().Foreground(styles.TextFaint) + input.Cursor.Style = lipgloss.NewStyle().Foreground(styles.Purple) + input.Focus() + + return &saveScreen{ + store: store, + sql: sql, + input: input, + width: width, + } +} + +func (s *saveScreen) Title() string { + return "SAVE QUERY" +} + +func (s *saveScreen) KeyHints() string { + return formatHint("enter", "save") + + styles.HintSep.String() + formatHint("esc", "cancel") +} + +func (s *saveScreen) Init() tea.Cmd { + return textinput.Blink +} + +func (s *saveScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.width = msg.Width + s.height = msg.Height - 1 + s.input.Width = s.width - 20 + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + return s, func() tea.Msg { return PopScreenMsg{} } + + case tea.KeyEnter: + name := strings.TrimSpace(s.input.Value()) + if name == "" { + return s, func() tea.Msg { return PopScreenMsg{} } + } + + if err := s.store.Save(name, s.sql); err != nil { + return s, func() tea.Msg { return PopScreenMsg{} } + } + + return s, tea.Sequence( + func() tea.Msg { return PopScreenMsg{} }, + func() tea.Msg { return querySavedMsg{name: name} }, + ) + } + } + + var cmd tea.Cmd + s.input, cmd = s.input.Update(msg) + return s, cmd +} + +func (s *saveScreen) View() string { + label := styles.HintDesc.Render("Query name: ") + content := label + s.input.View() + return styles.TitledBoxTopLeft(s.Title(), content, s.width, s.height) +} diff --git a/internal/tui/screen_saved.go b/internal/tui/screen_saved.go new file mode 100644 index 0000000..ac17c48 --- /dev/null +++ b/internal/tui/screen_saved.go @@ -0,0 +1,171 @@ +// Copyright (C) 2026 Alexey Zapparov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package tui + +import ( + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ixti/apiska/internal/storage" + "github.com/ixti/apiska/internal/tui/styles" +) + +type savedScreen struct { + store *storage.Store + queries []storage.SavedQuery + table table.Model + + width, height int +} + +func newSavedScreen(store *storage.Store) *savedScreen { + columns := []table.Column{ + {Title: "Name", Width: 25}, + {Title: "Created", Width: 20}, + {Title: "SQL", Width: 40}, + } + + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + ) + + s := &savedScreen{ + store: store, + table: t, + } + s.refreshTable() + + return s +} + +func (s *savedScreen) Title() string { + return "SAVED QUERIES" +} + +func (s *savedScreen) KeyHints() string { + if len(s.queries) == 0 { + return "" + } + return formatHint("↑↓", "navigate") + + styles.HintSep.String() + formatHint("enter", "load") + + styles.HintSep.String() + formatHint("d", "delete") +} + +func (s *savedScreen) Init() tea.Cmd { + return nil +} + +func (s *savedScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.width = msg.Width + s.height = msg.Height - 1 // account for footer + s.updateTableSize() + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + if q := s.selectedQuery(); q != nil { + return s, func() tea.Msg { + return loadSavedQueryMsg{sql: q.SQL} + } + } + return s, nil + } + + switch msg.String() { + case "d": + if q := s.selectedQuery(); q != nil { + s.store.Delete(q.ID) + s.refreshTable() + } + return s, nil + } + } + + // Only update table if we have queries + if len(s.queries) > 0 { + var cmd tea.Cmd + s.table, cmd = s.table.Update(msg) + return s, cmd + } + + return s, nil +} + +func (s *savedScreen) View() string { + if s.width == 0 || s.height == 0 { + return "Loading..." + } + + if len(s.queries) == 0 { + emptyMsg := lipgloss.NewStyle().Foreground(styles.TextMuted).Render("No saved queries yet.") + return styles.TitledBox(s.Title(), emptyMsg, s.width, s.height) + } + + return styles.TitledBoxTopLeft(s.Title(), s.table.View(), s.width, s.height) +} + +func (s *savedScreen) updateTableSize() { + // Width: screen width - 2 for box borders + tableWidth := max(s.width-2, 10) + + // Height: screen height - 2 for box borders - 2 for table header + tableHeight := max(s.height-2-2, 1) + + s.table.SetWidth(tableWidth) + s.table.SetHeight(tableHeight) + s.table.SetStyles(styles.TableStyles(tableWidth)) + + // Distribute column widths + const separatorWidth = 3 + nameWidth := 25 + createdWidth := 20 + sqlWidth := max(tableWidth-nameWidth-createdWidth-separatorWidth*2, 20) + + s.table.SetColumns([]table.Column{ + {Title: "Name", Width: nameWidth}, + {Title: "Created", Width: createdWidth}, + {Title: "SQL", Width: sqlWidth}, + }) +} + +func (s *savedScreen) selectedQuery() *storage.SavedQuery { + idx := s.table.Cursor() + if idx >= 0 && idx < len(s.queries) { + return &s.queries[idx] + } + return nil +} + +func (s *savedScreen) refreshTable() { + s.queries = s.store.List() + + rows := make([]table.Row, len(s.queries)) + for i, q := range s.queries { + rows[i] = table.Row{ + q.Name, + q.CreatedAt.Format("2006-01-02 15:04"), + truncateSQL(q.SQL, 50), + } + } + s.table.SetRows(rows) +} + +func truncateSQL(sql string, maxLen int) string { + // Replace newlines with spaces + result := "" + for _, c := range sql { + if c == '\n' || c == '\r' || c == '\t' { + result += " " + } else { + result += string(c) + } + } + if len(result) > maxLen { + result = result[:maxLen-3] + "..." + } + return result +} From 8042199a9bee4dcd92736948c426f1c7ff7043c4 Mon Sep 17 00:00:00 2001 From: Mesha Edwardson Date: Tue, 17 Feb 2026 21:29:10 -0800 Subject: [PATCH 2/4] fix: reduce amount of store passing --- internal/tui/model.go | 117 +++++++++++++++++----------------- internal/tui/screen_editor.go | 17 +---- internal/tui/screen_home.go | 20 ++---- internal/tui/screen_query.go | 15 ++--- internal/tui/screen_saved.go | 2 +- 5 files changed, 71 insertions(+), 100 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index a70bdf1..382b96f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -27,14 +27,28 @@ type Screen interface { KeyHints() string } -// PushScreenMsg pushes a new screen onto the stack. -type PushScreenMsg struct { - Screen Screen -} - // PopScreenMsg pops the current screen from the stack. type PopScreenMsg struct{} +// openEditorMsg opens the editor screen with optional initial SQL. +type openEditorMsg struct { + sql string +} + +// openSavedQueriesMsg opens the saved queries screen. +type openSavedQueriesMsg struct{} + +// openSavePromptMsg opens the save prompt for the given SQL. +type openSavePromptMsg struct { + sql string +} + +// openQueryMsg opens a query screen to view/execute a query. +type openQueryMsg struct { + query *rds.Query + execute bool +} + // queryAddedMsg is sent when a query should be added to history. type queryAddedMsg struct { query *rds.Query @@ -52,18 +66,6 @@ type editorFinishedMsg struct { err error } -// submitQueryMsg is sent when a query is submitted for execution. -// It handles: adding to history, popping editor, pushing query screen, starting execution. -type submitQueryMsg struct { - query *rds.Query - screen Screen -} - -// loadSavedQueryMsg is sent when a saved query should be loaded into a new editor. -type loadSavedQueryMsg struct { - sql string -} - // querySavedMsg is sent when a query has been saved successfully. type querySavedMsg struct { name string @@ -108,21 +110,42 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height - case PushScreenMsg: - m.screens = append(m.screens, msg.Screen) - // Forward current window size to the new screen - if m.width > 0 && m.height > 0 { - updated, _ := msg.Screen.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - m.screens[len(m.screens)-1] = updated.(Screen) - } - return m, msg.Screen.Init() - case PopScreenMsg: if len(m.screens) > 1 { m.screens = m.screens[:len(m.screens)-1] } return m, nil + case openEditorMsg: + return m, m.pushScreen(newEditorScreen(m.client, msg.sql)) + + case openSavedQueriesMsg: + return m, m.pushScreen(newSavedScreen(m.store)) + + case openSavePromptMsg: + return m, m.pushScreen(newSaveScreen(m.store, msg.sql, m.width)) + + case openQueryMsg: + // Add query to home screen history if executing + if msg.execute && len(m.screens) > 0 { + updated, _ := m.screens[0].Update(queryAddedMsg{query: msg.query}) + m.screens[0] = updated.(Screen) + } + // Pop current screen (editor) if executing + if msg.execute && len(m.screens) > 1 { + m.screens = m.screens[:len(m.screens)-1] + } + // Push query screen + screen := newQueryScreen(m.client, msg.query) + if msg.execute { + screen.executing = true + } + cmd := m.pushScreen(screen) + if msg.execute { + return m, tea.Batch(cmd, screen.executeQuery()) + } + return m, cmd + case queryAddedMsg: // Forward to home screen (first in stack) to update query history if len(m.screens) > 0 { @@ -147,35 +170,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.screens[0] = updated.(Screen) } return m, cmd - - case submitQueryMsg: - // Add query to home screen history - if len(m.screens) > 0 { - updated, _ := m.screens[0].Update(queryAddedMsg{query: msg.query}) - m.screens[0] = updated.(Screen) - } - // Pop current screen (editor) - if len(m.screens) > 1 { - m.screens = m.screens[:len(m.screens)-1] - } - // Push query screen and start execution - m.screens = append(m.screens, msg.screen) - // Forward current window size to the new screen - if m.width > 0 && m.height > 0 { - updated, _ := msg.screen.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - m.screens[len(m.screens)-1] = updated.(Screen) - } - return m, msg.screen.Init() - - case loadSavedQueryMsg: - // Push editor with SQL (keep saved queries screen in stack for back navigation) - screen := newEditorScreen(m.client, m.store, msg.sql) - m.screens = append(m.screens, screen) - if m.width > 0 && m.height > 0 { - updated, _ := screen.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - m.screens[len(m.screens)-1] = updated.(Screen) - } - return m, screen.Init() } // Delegate to the current screen @@ -227,16 +221,21 @@ func formatHint(key, desc string) string { return styles.HintKey.Render(key) + " " + styles.HintDesc.Render(desc) } -// ContentSize returns the available size for screen content. -func (m model) ContentSize() (width, height int) { - return m.width, m.height - 1 +// pushScreen adds a screen to the stack, forwards window size, and returns its Init cmd. +func (m *model) pushScreen(screen Screen) tea.Cmd { + m.screens = append(m.screens, screen) + if m.width > 0 && m.height > 0 { + updated, _ := screen.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.screens[len(m.screens)-1] = updated.(Screen) + } + return screen.Init() } func NewProgram(client *rds.Client, store *storage.Store) *tea.Program { return tea.NewProgram(model{ client: client, store: store, - screens: []Screen{newHomeScreen(client, store)}, + screens: []Screen{newHomeScreen()}, }) } diff --git a/internal/tui/screen_editor.go b/internal/tui/screen_editor.go index 51ca552..5a6d24c 100644 --- a/internal/tui/screen_editor.go +++ b/internal/tui/screen_editor.go @@ -11,13 +11,11 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/ixti/apiska/internal/launchers" "github.com/ixti/apiska/internal/rds" - "github.com/ixti/apiska/internal/storage" "github.com/ixti/apiska/internal/tui/styles" ) type editorScreen struct { client *rds.Client - store *storage.Store textarea textarea.Model err error saveMsg string @@ -25,7 +23,7 @@ type editorScreen struct { width, height int } -func newEditorScreen(client *rds.Client, store *storage.Store, initialSQL string) *editorScreen { +func newEditorScreen(client *rds.Client, initialSQL string) *editorScreen { ta := textarea.New() ta.Placeholder = "SELECT * FROM ..." ta.SetValue(initialSQL) @@ -40,7 +38,6 @@ func newEditorScreen(client *rds.Client, store *storage.Store, initialSQL string return &editorScreen{ client: client, - store: store, textarea: ta, } } @@ -80,9 +77,7 @@ func (s *editorScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if sql == "" { return s, nil } - return s, func() tea.Msg { - return PushScreenMsg{Screen: newSaveScreen(s.store, sql, s.width)} - } + return s, func() tea.Msg { return openSavePromptMsg{sql: sql} } } case editorFinishedMsg: @@ -155,14 +150,8 @@ func (s *editorScreen) executeQuery() tea.Cmd { return nil } - // Create query screen that will execute in background - queryScreen := newQueryScreen(query) - queryScreen.client = s.client - queryScreen.store = s.store - queryScreen.executing = true - return func() tea.Msg { - return submitQueryMsg{query: query, screen: queryScreen} + return openQueryMsg{query: query, execute: true} } } diff --git a/internal/tui/screen_home.go b/internal/tui/screen_home.go index 1e2998a..649a0c2 100644 --- a/internal/tui/screen_home.go +++ b/internal/tui/screen_home.go @@ -8,20 +8,17 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/ixti/apiska/internal/rds" - "github.com/ixti/apiska/internal/storage" "github.com/ixti/apiska/internal/tui/styles" ) type homeScreen struct { - client *rds.Client - store *storage.Store queries []*rds.Query table table.Model width, height int } -func newHomeScreen(client *rds.Client, store *storage.Store) *homeScreen { +func newHomeScreen() *homeScreen { columns := []table.Column{ {Title: "Label", Width: 20}, {Title: "Time", Width: 20}, @@ -34,8 +31,6 @@ func newHomeScreen(client *rds.Client, store *storage.Store) *homeScreen { ) return &homeScreen{ - client: client, - store: store, queries: []*rds.Query{}, table: t, } @@ -72,24 +67,17 @@ func (s *homeScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEnter: if q := s.selectedQuery(); q != nil { - screen := newQueryScreen(q) - screen.client = s.client - screen.store = s.store return s, func() tea.Msg { - return PushScreenMsg{Screen: screen} + return openQueryMsg{query: q, execute: false} } } return s, nil case tea.KeyCtrlN: - return s, func() tea.Msg { - return PushScreenMsg{Screen: newEditorScreen(s.client, s.store, "")} - } + return s, func() tea.Msg { return openEditorMsg{} } case tea.KeyCtrlL: - return s, func() tea.Msg { - return PushScreenMsg{Screen: newSavedScreen(s.store)} - } + return s, func() tea.Msg { return openSavedQueriesMsg{} } } case queryAddedMsg: diff --git a/internal/tui/screen_query.go b/internal/tui/screen_query.go index 501da84..889eecf 100644 --- a/internal/tui/screen_query.go +++ b/internal/tui/screen_query.go @@ -17,7 +17,6 @@ import ( "github.com/ixti/apiska/internal/formatters" "github.com/ixti/apiska/internal/launchers" "github.com/ixti/apiska/internal/rds" - "github.com/ixti/apiska/internal/storage" "github.com/ixti/apiska/internal/tui/styles" ) @@ -34,7 +33,6 @@ type csvExportedMsg struct { type queryScreen struct { client *rds.Client - store *storage.Store query *rds.Query table table.Model executing bool @@ -47,7 +45,7 @@ type queryScreen struct { width, height int } -func newQueryScreen(query *rds.Query) *queryScreen { +func newQueryScreen(client *rds.Client, query *rds.Query) *queryScreen { t := table.New( table.WithColumns([]table.Column{}), table.WithRows([]table.Row{}), @@ -55,8 +53,9 @@ func newQueryScreen(query *rds.Query) *queryScreen { ) return &queryScreen{ - query: query, - table: t, + client: client, + query: query, + table: t, } } @@ -80,9 +79,6 @@ func (s *queryScreen) KeyHints() string { } func (s *queryScreen) Init() tea.Cmd { - if s.executing && s.client != nil { - return s.executeQuery() - } return nil } @@ -104,10 +100,9 @@ func (s *queryScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlN: // Clone: pop current screen and open editor with current SQL - initialSQL := s.query.SQL return s, tea.Sequence( func() tea.Msg { return PopScreenMsg{} }, - func() tea.Msg { return PushScreenMsg{Screen: newEditorScreen(s.client, s.store, initialSQL)} }, + func() tea.Msg { return openEditorMsg{sql: s.query.SQL} }, ) case tea.KeyCtrlE: // Export as CSV diff --git a/internal/tui/screen_saved.go b/internal/tui/screen_saved.go index ac17c48..b0e9b20 100644 --- a/internal/tui/screen_saved.go +++ b/internal/tui/screen_saved.go @@ -69,7 +69,7 @@ func (s *savedScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEnter: if q := s.selectedQuery(); q != nil { return s, func() tea.Msg { - return loadSavedQueryMsg{sql: q.SQL} + return openEditorMsg{sql: q.SQL} } } return s, nil From 7058fbc28c3d45fd4255af0b3b932bfdea9a2458 Mon Sep 17 00:00:00 2001 From: Mesha Edwardson Date: Tue, 17 Feb 2026 23:05:10 -0800 Subject: [PATCH 3/4] refactor: save into SQL files --- internal/storage/queries.go | 157 ++++++++++++++++++------------- internal/storage/queries_test.go | 132 +++++++++++++++----------- internal/tui/model.go | 26 ++--- internal/tui/screen_save.go | 5 +- internal/tui/screen_saved.go | 10 +- 5 files changed, 185 insertions(+), 145 deletions(-) diff --git a/internal/storage/queries.go b/internal/storage/queries.go index 168b817..95d0350 100644 --- a/internal/storage/queries.go +++ b/internal/storage/queries.go @@ -4,71 +4,56 @@ package storage import ( - "encoding/json" "fmt" "os" "path/filepath" + "regexp" + "sort" + "strings" "sync" "time" - - "github.com/ixti/apiska/internal/exporters" ) +var unsafeCharsRe = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) + // SavedQuery represents a query saved for later use -- a track in your crate. type SavedQuery struct { - ID string `json:"id"` - Name string `json:"name"` - SQL string `json:"sql"` - CreatedAt time.Time `json:"created_at"` -} - -// queriesFile is the JSON structure for persisting queries to disk. -type queriesFile struct { - Queries []SavedQuery `json:"queries"` + ID string + Name string + SQL string + UpdatedAt time.Time } // Store manages saved queries persistence -- the record store that never closes. +// Queries are stored as .sql files in ./.apiska/ type Store struct { - mu sync.RWMutex - path string - queries []SavedQuery + mu sync.RWMutex + path string } -// NewStore creates a new Store loading from ~/.apiska/queries.json. -// If the file doesn't exist, starts with an empty collection. +// NewStore creates a new Store using ./.apiska/ for query files. func NewStore() (*Store, error) { - home, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("failed to get home directory: %w", err) - } - - path := filepath.Join(home, ".apiska", "queries.json") - s := &Store{ - path: path, - queries: []SavedQuery{}, - } - - if err := s.load(); err != nil && !os.IsNotExist(err) { - return nil, err - } - - return s, nil + return &Store{path: ".apiska"}, nil } // Save stores a query with the given name -- pressing a new record. -func (s *Store) Save(name, sql string) error { +// Returns the actual filename that was used. +func (s *Store) Save(name, sql string) (string, error) { s.mu.Lock() defer s.mu.Unlock() - query := SavedQuery{ - ID: fmt.Sprintf("q-%d", time.Now().UnixNano()), - Name: name, - SQL: sql, - CreatedAt: time.Now(), + if err := os.MkdirAll(s.path, 0755); err != nil { + return "", fmt.Errorf("failed to create .apiska directory: %w", err) } - s.queries = append([]SavedQuery{query}, s.queries...) - return s.persist() + filename := sanitizeFilename(name) + ".sql" + filePath := uniqueFilePath(filepath.Join(s.path, filename)) + + if err := os.WriteFile(filePath, []byte(sql), 0644); err != nil { + return "", err + } + + return filepath.Base(filePath), nil } // List returns all saved queries -- browsing the crate. @@ -76,9 +61,42 @@ func (s *Store) List() []SavedQuery { s.mu.RLock() defer s.mu.RUnlock() - result := make([]SavedQuery, len(s.queries)) - copy(result, s.queries) - return result + entries, err := os.ReadDir(s.path) + if err != nil { + return []SavedQuery{} + } + + var queries []SavedQuery + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + + filePath := filepath.Join(s.path, entry.Name()) + content, err := os.ReadFile(filePath) + if err != nil { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + name := strings.TrimSuffix(entry.Name(), ".sql") + queries = append(queries, SavedQuery{ + ID: entry.Name(), + Name: name, + SQL: string(content), + UpdatedAt: info.ModTime(), + }) + } + + sort.Slice(queries, func(i, j int) bool { + return queries[i].UpdatedAt.After(queries[j].UpdatedAt) + }) + + return queries } // Delete removes a saved query by ID -- pulling a record from the crate. @@ -86,37 +104,40 @@ func (s *Store) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() - for i, q := range s.queries { - if q.ID == id { - s.queries = append(s.queries[:i], s.queries[i+1:]...) - return s.persist() - } + filePath := filepath.Join(s.path, id) + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return err } return nil } -// load reads queries from disk -- opening the crate. -func (s *Store) load() error { - data, err := os.ReadFile(s.path) - if err != nil { - return err - } +// sanitizeFilename converts a query name to a safe filename. +func sanitizeFilename(name string) string { + name = strings.ToLower(name) + name = strings.ReplaceAll(name, " ", "_") + name = unsafeCharsRe.ReplaceAllString(name, "") + name = strings.Trim(name, "._ ") - var file queriesFile - if err := json.Unmarshal(data, &file); err != nil { - return fmt.Errorf("failed to parse queries file: %w", err) + if name == "" { + name = "query" } - s.queries = file.Queries - return nil + return name } -// persist writes queries to disk -- closing the crate for the night. -func (s *Store) persist() error { - file := queriesFile{Queries: s.queries} - _, err := exporters.WriteJson(s.path, func(enc *json.Encoder) error { - enc.SetIndent("", " ") - return enc.Encode(file) - }) - return err +// uniqueFilePath returns a unique file path by appending a numeric suffix if needed. +func uniqueFilePath(filePath string) string { + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return filePath + } + + ext := filepath.Ext(filePath) + base := strings.TrimSuffix(filePath, ext) + + for i := 1; ; i++ { + newPath := fmt.Sprintf("%s_%d%s", base, i, ext) + if _, err := os.Stat(newPath); os.IsNotExist(err) { + return newPath + } + } } diff --git a/internal/storage/queries_test.go b/internal/storage/queries_test.go index 4b154af..ce7ff43 100644 --- a/internal/storage/queries_test.go +++ b/internal/storage/queries_test.go @@ -13,123 +13,147 @@ import ( ) func TestStore_SaveAndList(t *testing.T) { - // Create temp directory for test tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "queries.json") + store := &Store{path: tmpDir} - store := &Store{path: path, queries: []SavedQuery{}} - - // Save a query - err := store.Save("Test Query", "SELECT * FROM users") + actualName, err := store.Save("Test Query", "SELECT * FROM users") require.NoError(t, err) + assert.Equal(t, "test_query.sql", actualName) - // List should return the saved query queries := store.List() require.Len(t, queries, 1) - assert.Equal(t, "Test Query", queries[0].Name) + assert.Equal(t, "test_query", queries[0].Name) assert.Equal(t, "SELECT * FROM users", queries[0].SQL) - assert.NotEmpty(t, queries[0].ID) - assert.False(t, queries[0].CreatedAt.IsZero()) + assert.Equal(t, "test_query.sql", queries[0].ID) + assert.False(t, queries[0].UpdatedAt.IsZero()) + + // Verify file exists + _, err = os.Stat(filepath.Join(tmpDir, "test_query.sql")) + require.NoError(t, err) } func TestStore_SaveMultiple(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "queries.json") + store := &Store{path: tmpDir} + + _, err := store.Save("Query 1", "SELECT 1") + require.NoError(t, err) + + _, err = store.Save("Query 2", "SELECT 2") + require.NoError(t, err) + + queries := store.List() + require.Len(t, queries, 2) + + // Newest should be first (sorted by mod time) + assert.Equal(t, "query_2", queries[0].Name) + assert.Equal(t, "query_1", queries[1].Name) +} - store := &Store{path: path, queries: []SavedQuery{}} +func TestStore_SaveDuplicateName(t *testing.T) { + tmpDir := t.TempDir() + store := &Store{path: tmpDir} - err := store.Save("Query 1", "SELECT 1") + name1, err := store.Save("Query", "SELECT 1") require.NoError(t, err) + assert.Equal(t, "query.sql", name1) - err = store.Save("Query 2", "SELECT 2") + name2, err := store.Save("Query", "SELECT 2") require.NoError(t, err) + assert.Equal(t, "query_1.sql", name2) queries := store.List() require.Len(t, queries, 2) - // Newest should be first - assert.Equal(t, "Query 2", queries[0].Name) - assert.Equal(t, "Query 1", queries[1].Name) + // Should have both query.sql and query_1.sql + _, err = os.Stat(filepath.Join(tmpDir, "query.sql")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(tmpDir, "query_1.sql")) + require.NoError(t, err) } func TestStore_Delete(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "queries.json") + store := &Store{path: tmpDir} - store := &Store{path: path, queries: []SavedQuery{}} - - err := store.Save("Query 1", "SELECT 1") + _, err := store.Save("Query 1", "SELECT 1") require.NoError(t, err) - err = store.Save("Query 2", "SELECT 2") + _, err = store.Save("Query 2", "SELECT 2") require.NoError(t, err) queries := store.List() require.Len(t, queries, 2) - // Delete first query + // Delete newest query err = store.Delete(queries[0].ID) require.NoError(t, err) queries = store.List() require.Len(t, queries, 1) - assert.Equal(t, "Query 1", queries[0].Name) + assert.Equal(t, "query_1", queries[0].Name) } func TestStore_DeleteNonExistent(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "queries.json") - - store := &Store{path: path, queries: []SavedQuery{}} + store := &Store{path: tmpDir} // Deleting non-existent ID should not error - err := store.Delete("nonexistent-id") + err := store.Delete("nonexistent.sql") require.NoError(t, err) } func TestStore_Persistence(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "queries.json") // Create first store and save - store1 := &Store{path: path, queries: []SavedQuery{}} - err := store1.Save("Persisted Query", "SELECT * FROM orders") + store1 := &Store{path: tmpDir} + _, err := store1.Save("Persisted Query", "SELECT * FROM orders") require.NoError(t, err) - // Create second store and load from same file - store2 := &Store{path: path, queries: []SavedQuery{}} - err = store2.load() - require.NoError(t, err) + // Create second store pointing to same directory + store2 := &Store{path: tmpDir} queries := store2.List() require.Len(t, queries, 1) - assert.Equal(t, "Persisted Query", queries[0].Name) + assert.Equal(t, "persisted_query", queries[0].Name) assert.Equal(t, "SELECT * FROM orders", queries[0].SQL) } -func TestStore_LoadNonExistent(t *testing.T) { +func TestStore_ListEmptyDirectory(t *testing.T) { tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "nonexistent.json") + store := &Store{path: tmpDir} - store := &Store{path: path, queries: []SavedQuery{}} - err := store.load() - - assert.True(t, os.IsNotExist(err)) + queries := store.List() + assert.Empty(t, queries) } -func TestStore_ListReturnsDefensiveCopy(t *testing.T) { - tmpDir := t.TempDir() - path := filepath.Join(tmpDir, "queries.json") - - store := &Store{path: path, queries: []SavedQuery{}} - err := store.Save("Original", "SELECT 1") - require.NoError(t, err) +func TestStore_ListNonExistentDirectory(t *testing.T) { + store := &Store{path: "/nonexistent/path"} - // Modify the returned slice queries := store.List() - queries[0].Name = "Modified" + assert.Empty(t, queries) +} - // Original should be unchanged - queries2 := store.List() - assert.Equal(t, "Original", queries2[0].Name) +func TestSanitizeFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Simple Query", "simple_query"}, + {"Query/With/Slashes", "querywithslashes"}, + {"Query:With:Colons", "querywithcolons"}, + {"Query<>With<>Brackets", "querywithbrackets"}, + {"...", "query"}, + {"", "query"}, + {" ", "query"}, + {"Normal_Name", "normal_name"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := sanitizeFilename(tt.input) + assert.Equal(t, tt.expected, result) + }) + } } diff --git a/internal/tui/model.go b/internal/tui/model.go index 382b96f..6529a98 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -170,6 +170,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.screens[0] = updated.(Screen) } return m, cmd + + case querySavedMsg: + // Forward to all screens so any savedScreen in the stack can refresh with new saved query list + for i := range m.screens { + updated, _ := m.screens[i].Update(msg) + m.screens[i] = updated.(Screen) + } + return m, nil } // Delegate to the current screen @@ -238,21 +246,3 @@ func NewProgram(client *rds.Client, store *storage.Store) *tea.Program { screens: []Screen{newHomeScreen()}, }) } - - - - - - - - - - - - - - - - - - diff --git a/internal/tui/screen_save.go b/internal/tui/screen_save.go index 290b940..6954d54 100644 --- a/internal/tui/screen_save.go +++ b/internal/tui/screen_save.go @@ -69,13 +69,14 @@ func (s *saveScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, func() tea.Msg { return PopScreenMsg{} } } - if err := s.store.Save(name, s.sql); err != nil { + actualName, err := s.store.Save(name, s.sql) + if err != nil { return s, func() tea.Msg { return PopScreenMsg{} } } return s, tea.Sequence( func() tea.Msg { return PopScreenMsg{} }, - func() tea.Msg { return querySavedMsg{name: name} }, + func() tea.Msg { return querySavedMsg{name: actualName} }, ) } } diff --git a/internal/tui/screen_saved.go b/internal/tui/screen_saved.go index b0e9b20..fc1b679 100644 --- a/internal/tui/screen_saved.go +++ b/internal/tui/screen_saved.go @@ -22,7 +22,7 @@ type savedScreen struct { func newSavedScreen(store *storage.Store) *savedScreen { columns := []table.Column{ {Title: "Name", Width: 25}, - {Title: "Created", Width: 20}, + {Title: "Updated", Width: 20}, {Title: "SQL", Width: 40}, } @@ -64,6 +64,10 @@ func (s *savedScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.height = msg.Height - 1 // account for footer s.updateTableSize() + case querySavedMsg: + s.refreshTable() + return s, nil + case tea.KeyMsg: switch msg.Type { case tea.KeyEnter: @@ -127,7 +131,7 @@ func (s *savedScreen) updateTableSize() { s.table.SetColumns([]table.Column{ {Title: "Name", Width: nameWidth}, - {Title: "Created", Width: createdWidth}, + {Title: "Updated", Width: createdWidth}, {Title: "SQL", Width: sqlWidth}, }) } @@ -147,7 +151,7 @@ func (s *savedScreen) refreshTable() { for i, q := range s.queries { rows[i] = table.Row{ q.Name, - q.CreatedAt.Format("2006-01-02 15:04"), + q.UpdatedAt.Format("2006-01-02 15:04"), truncateSQL(q.SQL, 50), } } From 9c4eade9b1f0f24d5818c4ab0ff8bcc7e71b185a Mon Sep 17 00:00:00 2001 From: Mesha Edwardson Date: Wed, 18 Feb 2026 08:41:16 -0800 Subject: [PATCH 4/4] refactor: no saving, only reading --- internal/storage/queries.go | 66 -------------------- internal/storage/queries_test.go | 100 +++++-------------------------- internal/tui/model.go | 21 ------- internal/tui/screen_editor.go | 18 ------ internal/tui/screen_save.go | 93 ---------------------------- internal/tui/screen_saved.go | 16 +---- 6 files changed, 17 insertions(+), 297 deletions(-) delete mode 100644 internal/tui/screen_save.go diff --git a/internal/storage/queries.go b/internal/storage/queries.go index 95d0350..a808410 100644 --- a/internal/storage/queries.go +++ b/internal/storage/queries.go @@ -4,18 +4,14 @@ package storage import ( - "fmt" "os" "path/filepath" - "regexp" "sort" "strings" "sync" "time" ) -var unsafeCharsRe = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`) - // SavedQuery represents a query saved for later use -- a track in your crate. type SavedQuery struct { ID string @@ -36,26 +32,6 @@ func NewStore() (*Store, error) { return &Store{path: ".apiska"}, nil } -// Save stores a query with the given name -- pressing a new record. -// Returns the actual filename that was used. -func (s *Store) Save(name, sql string) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if err := os.MkdirAll(s.path, 0755); err != nil { - return "", fmt.Errorf("failed to create .apiska directory: %w", err) - } - - filename := sanitizeFilename(name) + ".sql" - filePath := uniqueFilePath(filepath.Join(s.path, filename)) - - if err := os.WriteFile(filePath, []byte(sql), 0644); err != nil { - return "", err - } - - return filepath.Base(filePath), nil -} - // List returns all saved queries -- browsing the crate. func (s *Store) List() []SavedQuery { s.mu.RLock() @@ -99,45 +75,3 @@ func (s *Store) List() []SavedQuery { return queries } -// Delete removes a saved query by ID -- pulling a record from the crate. -func (s *Store) Delete(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - filePath := filepath.Join(s.path, id) - if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} - -// sanitizeFilename converts a query name to a safe filename. -func sanitizeFilename(name string) string { - name = strings.ToLower(name) - name = strings.ReplaceAll(name, " ", "_") - name = unsafeCharsRe.ReplaceAllString(name, "") - name = strings.Trim(name, "._ ") - - if name == "" { - name = "query" - } - - return name -} - -// uniqueFilePath returns a unique file path by appending a numeric suffix if needed. -func uniqueFilePath(filePath string) string { - if _, err := os.Stat(filePath); os.IsNotExist(err) { - return filePath - } - - ext := filepath.Ext(filePath) - base := strings.TrimSuffix(filePath, ext) - - for i := 1; ; i++ { - newPath := fmt.Sprintf("%s_%d%s", base, i, ext) - if _, err := os.Stat(newPath); os.IsNotExist(err) { - return newPath - } - } -} diff --git a/internal/storage/queries_test.go b/internal/storage/queries_test.go index ce7ff43..2076753 100644 --- a/internal/storage/queries_test.go +++ b/internal/storage/queries_test.go @@ -12,13 +12,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestStore_SaveAndList(t *testing.T) { +func TestStore_List(t *testing.T) { tmpDir := t.TempDir() store := &Store{path: tmpDir} - actualName, err := store.Save("Test Query", "SELECT * FROM users") + // Create a .sql file manually + err := os.WriteFile(filepath.Join(tmpDir, "test_query.sql"), []byte("SELECT * FROM users"), 0644) require.NoError(t, err) - assert.Equal(t, "test_query.sql", actualName) queries := store.List() require.Len(t, queries, 1) @@ -26,98 +26,53 @@ func TestStore_SaveAndList(t *testing.T) { assert.Equal(t, "SELECT * FROM users", queries[0].SQL) assert.Equal(t, "test_query.sql", queries[0].ID) assert.False(t, queries[0].UpdatedAt.IsZero()) - - // Verify file exists - _, err = os.Stat(filepath.Join(tmpDir, "test_query.sql")) - require.NoError(t, err) } -func TestStore_SaveMultiple(t *testing.T) { +func TestStore_ListMultiple(t *testing.T) { tmpDir := t.TempDir() store := &Store{path: tmpDir} - _, err := store.Save("Query 1", "SELECT 1") + err := os.WriteFile(filepath.Join(tmpDir, "query_1.sql"), []byte("SELECT 1"), 0644) require.NoError(t, err) - _, err = store.Save("Query 2", "SELECT 2") + err = os.WriteFile(filepath.Join(tmpDir, "query_2.sql"), []byte("SELECT 2"), 0644) require.NoError(t, err) queries := store.List() require.Len(t, queries, 2) - - // Newest should be first (sorted by mod time) - assert.Equal(t, "query_2", queries[0].Name) - assert.Equal(t, "query_1", queries[1].Name) } -func TestStore_SaveDuplicateName(t *testing.T) { +func TestStore_ListIgnoresNonSqlFiles(t *testing.T) { tmpDir := t.TempDir() store := &Store{path: tmpDir} - name1, err := store.Save("Query", "SELECT 1") + err := os.WriteFile(filepath.Join(tmpDir, "query.sql"), []byte("SELECT 1"), 0644) require.NoError(t, err) - assert.Equal(t, "query.sql", name1) - - name2, err := store.Save("Query", "SELECT 2") - require.NoError(t, err) - assert.Equal(t, "query_1.sql", name2) - - queries := store.List() - require.Len(t, queries, 2) - - // Should have both query.sql and query_1.sql - _, err = os.Stat(filepath.Join(tmpDir, "query.sql")) - require.NoError(t, err) - _, err = os.Stat(filepath.Join(tmpDir, "query_1.sql")) - require.NoError(t, err) -} - -func TestStore_Delete(t *testing.T) { - tmpDir := t.TempDir() - store := &Store{path: tmpDir} - _, err := store.Save("Query 1", "SELECT 1") + err = os.WriteFile(filepath.Join(tmpDir, "readme.txt"), []byte("not a query"), 0644) require.NoError(t, err) - _, err = store.Save("Query 2", "SELECT 2") + err = os.WriteFile(filepath.Join(tmpDir, "notes.md"), []byte("some notes"), 0644) require.NoError(t, err) queries := store.List() - require.Len(t, queries, 2) - - // Delete newest query - err = store.Delete(queries[0].ID) - require.NoError(t, err) - - queries = store.List() require.Len(t, queries, 1) - assert.Equal(t, "query_1", queries[0].Name) + assert.Equal(t, "query", queries[0].Name) } -func TestStore_DeleteNonExistent(t *testing.T) { +func TestStore_ListIgnoresDirectories(t *testing.T) { tmpDir := t.TempDir() store := &Store{path: tmpDir} - // Deleting non-existent ID should not error - err := store.Delete("nonexistent.sql") + err := os.WriteFile(filepath.Join(tmpDir, "query.sql"), []byte("SELECT 1"), 0644) require.NoError(t, err) -} -func TestStore_Persistence(t *testing.T) { - tmpDir := t.TempDir() - - // Create first store and save - store1 := &Store{path: tmpDir} - _, err := store1.Save("Persisted Query", "SELECT * FROM orders") + err = os.Mkdir(filepath.Join(tmpDir, "subdir.sql"), 0755) require.NoError(t, err) - // Create second store pointing to same directory - store2 := &Store{path: tmpDir} - - queries := store2.List() + queries := store.List() require.Len(t, queries, 1) - assert.Equal(t, "persisted_query", queries[0].Name) - assert.Equal(t, "SELECT * FROM orders", queries[0].SQL) + assert.Equal(t, "query", queries[0].Name) } func TestStore_ListEmptyDirectory(t *testing.T) { @@ -134,26 +89,3 @@ func TestStore_ListNonExistentDirectory(t *testing.T) { queries := store.List() assert.Empty(t, queries) } - -func TestSanitizeFilename(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"Simple Query", "simple_query"}, - {"Query/With/Slashes", "querywithslashes"}, - {"Query:With:Colons", "querywithcolons"}, - {"Query<>With<>Brackets", "querywithbrackets"}, - {"...", "query"}, - {"", "query"}, - {" ", "query"}, - {"Normal_Name", "normal_name"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := sanitizeFilename(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/internal/tui/model.go b/internal/tui/model.go index 6529a98..78b5f9f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -38,11 +38,6 @@ type openEditorMsg struct { // openSavedQueriesMsg opens the saved queries screen. type openSavedQueriesMsg struct{} -// openSavePromptMsg opens the save prompt for the given SQL. -type openSavePromptMsg struct { - sql string -} - // openQueryMsg opens a query screen to view/execute a query. type openQueryMsg struct { query *rds.Query @@ -66,11 +61,6 @@ type editorFinishedMsg struct { err error } -// querySavedMsg is sent when a query has been saved successfully. -type querySavedMsg struct { - name string -} - // model is the root model that manages the screen stack and chrome. type model struct { client *rds.Client @@ -122,9 +112,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case openSavedQueriesMsg: return m, m.pushScreen(newSavedScreen(m.store)) - case openSavePromptMsg: - return m, m.pushScreen(newSaveScreen(m.store, msg.sql, m.width)) - case openQueryMsg: // Add query to home screen history if executing if msg.execute && len(m.screens) > 0 { @@ -170,14 +157,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.screens[0] = updated.(Screen) } return m, cmd - - case querySavedMsg: - // Forward to all screens so any savedScreen in the stack can refresh with new saved query list - for i := range m.screens { - updated, _ := m.screens[i].Update(msg) - m.screens[i] = updated.(Screen) - } - return m, nil } // Delegate to the current screen diff --git a/internal/tui/screen_editor.go b/internal/tui/screen_editor.go index 5a6d24c..8f4f03f 100644 --- a/internal/tui/screen_editor.go +++ b/internal/tui/screen_editor.go @@ -18,7 +18,6 @@ type editorScreen struct { client *rds.Client textarea textarea.Model err error - saveMsg string width, height int } @@ -48,7 +47,6 @@ func (s *editorScreen) Title() string { func (s *editorScreen) KeyHints() string { return formatHint("F5/ctrl+r", "execute") + - styles.HintSep.String() + formatHint("ctrl+s", "save") + styles.HintSep.String() + formatHint("ctrl+e", "external editor") } @@ -64,20 +62,11 @@ func (s *editorScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.updateTextareaSize() case tea.KeyMsg: - // Clear any status message when typing - s.saveMsg = "" - switch msg.Type { case tea.KeyF5, tea.KeyCtrlR: return s, s.executeQuery() case tea.KeyCtrlE: return s, s.openExternalEditor() - case tea.KeyCtrlS: - sql := strings.TrimSpace(s.textarea.Value()) - if sql == "" { - return s, nil - } - return s, func() tea.Msg { return openSavePromptMsg{sql: sql} } } case editorFinishedMsg: @@ -104,10 +93,6 @@ func (s *editorScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, nil } return s, nil - - case querySavedMsg: - s.saveMsg = "Saved: " + msg.name - return s, nil } var cmd tea.Cmd @@ -121,9 +106,6 @@ func (s *editorScreen) View() string { if s.err != nil { errStyle := lipgloss.NewStyle().Foreground(styles.Red) content = s.textarea.View() + "\n" + errStyle.Render("Error: "+s.err.Error()) - } else if s.saveMsg != "" { - msgStyle := lipgloss.NewStyle().Foreground(styles.Green) - content = s.textarea.View() + "\n" + msgStyle.Render(s.saveMsg) } else { content = s.textarea.View() } diff --git a/internal/tui/screen_save.go b/internal/tui/screen_save.go deleted file mode 100644 index 6954d54..0000000 --- a/internal/tui/screen_save.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (C) 2026 Alexey Zapparov -// SPDX-License-Identifier: AGPL-3.0-or-later - -package tui - -import ( - "strings" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/ixti/apiska/internal/storage" - "github.com/ixti/apiska/internal/tui/styles" -) - -type saveScreen struct { - store *storage.Store - sql string - input textinput.Model - - width, height int -} - -func newSaveScreen(store *storage.Store, sql string, width int) *saveScreen { - input := textinput.New() - input.Placeholder = "My query" - input.Width = width - 20 - input.TextStyle = lipgloss.NewStyle().Foreground(styles.Text) - input.PlaceholderStyle = lipgloss.NewStyle().Foreground(styles.TextFaint) - input.Cursor.Style = lipgloss.NewStyle().Foreground(styles.Purple) - input.Focus() - - return &saveScreen{ - store: store, - sql: sql, - input: input, - width: width, - } -} - -func (s *saveScreen) Title() string { - return "SAVE QUERY" -} - -func (s *saveScreen) KeyHints() string { - return formatHint("enter", "save") + - styles.HintSep.String() + formatHint("esc", "cancel") -} - -func (s *saveScreen) Init() tea.Cmd { - return textinput.Blink -} - -func (s *saveScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - s.width = msg.Width - s.height = msg.Height - 1 - s.input.Width = s.width - 20 - - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEsc: - return s, func() tea.Msg { return PopScreenMsg{} } - - case tea.KeyEnter: - name := strings.TrimSpace(s.input.Value()) - if name == "" { - return s, func() tea.Msg { return PopScreenMsg{} } - } - - actualName, err := s.store.Save(name, s.sql) - if err != nil { - return s, func() tea.Msg { return PopScreenMsg{} } - } - - return s, tea.Sequence( - func() tea.Msg { return PopScreenMsg{} }, - func() tea.Msg { return querySavedMsg{name: actualName} }, - ) - } - } - - var cmd tea.Cmd - s.input, cmd = s.input.Update(msg) - return s, cmd -} - -func (s *saveScreen) View() string { - label := styles.HintDesc.Render("Query name: ") - content := label + s.input.View() - return styles.TitledBoxTopLeft(s.Title(), content, s.width, s.height) -} diff --git a/internal/tui/screen_saved.go b/internal/tui/screen_saved.go index fc1b679..d1ebc9c 100644 --- a/internal/tui/screen_saved.go +++ b/internal/tui/screen_saved.go @@ -49,8 +49,7 @@ func (s *savedScreen) KeyHints() string { return "" } return formatHint("↑↓", "navigate") + - styles.HintSep.String() + formatHint("enter", "load") + - styles.HintSep.String() + formatHint("d", "delete") + styles.HintSep.String() + formatHint("enter", "load") } func (s *savedScreen) Init() tea.Cmd { @@ -64,10 +63,6 @@ func (s *savedScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.height = msg.Height - 1 // account for footer s.updateTableSize() - case querySavedMsg: - s.refreshTable() - return s, nil - case tea.KeyMsg: switch msg.Type { case tea.KeyEnter: @@ -78,15 +73,6 @@ func (s *savedScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return s, nil } - - switch msg.String() { - case "d": - if q := s.selectedQuery(); q != nil { - s.store.Delete(q.ID) - s.refreshTable() - } - return s, nil - } } // Only update table if we have queries