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..a808410 --- /dev/null +++ b/internal/storage/queries.go @@ -0,0 +1,77 @@ +// Copyright (C) 2026 Alexey Zapparov +// SPDX-License-Identifier: AGPL-3.0-or-later + +package storage + +import ( + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// SavedQuery represents a query saved for later use -- a track in your crate. +type SavedQuery struct { + 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 +} + +// NewStore creates a new Store using ./.apiska/ for query files. +func NewStore() (*Store, error) { + return &Store{path: ".apiska"}, nil +} + +// List returns all saved queries -- browsing the crate. +func (s *Store) List() []SavedQuery { + s.mu.RLock() + defer s.mu.RUnlock() + + 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 +} + diff --git a/internal/storage/queries_test.go b/internal/storage/queries_test.go new file mode 100644 index 0000000..2076753 --- /dev/null +++ b/internal/storage/queries_test.go @@ -0,0 +1,91 @@ +// 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_List(t *testing.T) { + tmpDir := t.TempDir() + store := &Store{path: tmpDir} + + // Create a .sql file manually + err := os.WriteFile(filepath.Join(tmpDir, "test_query.sql"), []byte("SELECT * FROM users"), 0644) + require.NoError(t, err) + + 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.Equal(t, "test_query.sql", queries[0].ID) + assert.False(t, queries[0].UpdatedAt.IsZero()) +} + +func TestStore_ListMultiple(t *testing.T) { + tmpDir := t.TempDir() + store := &Store{path: tmpDir} + + err := os.WriteFile(filepath.Join(tmpDir, "query_1.sql"), []byte("SELECT 1"), 0644) + require.NoError(t, err) + + 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) +} + +func TestStore_ListIgnoresNonSqlFiles(t *testing.T) { + tmpDir := t.TempDir() + store := &Store{path: tmpDir} + + err := os.WriteFile(filepath.Join(tmpDir, "query.sql"), []byte("SELECT 1"), 0644) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(tmpDir, "readme.txt"), []byte("not a query"), 0644) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(tmpDir, "notes.md"), []byte("some notes"), 0644) + require.NoError(t, err) + + queries := store.List() + require.Len(t, queries, 1) + assert.Equal(t, "query", queries[0].Name) +} + +func TestStore_ListIgnoresDirectories(t *testing.T) { + tmpDir := t.TempDir() + store := &Store{path: tmpDir} + + err := os.WriteFile(filepath.Join(tmpDir, "query.sql"), []byte("SELECT 1"), 0644) + require.NoError(t, err) + + err = os.Mkdir(filepath.Join(tmpDir, "subdir.sql"), 0755) + require.NoError(t, err) + + queries := store.List() + require.Len(t, queries, 1) + assert.Equal(t, "query", queries[0].Name) +} + +func TestStore_ListEmptyDirectory(t *testing.T) { + tmpDir := t.TempDir() + store := &Store{path: tmpDir} + + queries := store.List() + assert.Empty(t, queries) +} + +func TestStore_ListNonExistentDirectory(t *testing.T) { + store := &Store{path: "/nonexistent/path"} + + queries := store.List() + assert.Empty(t, queries) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 53208f3..78b5f9f 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" ) @@ -26,14 +27,23 @@ 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{} + +// 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 @@ -51,16 +61,10 @@ 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 -} - // model is the root model that manages the screen stack and chrome. type model struct { client *rds.Client + store *storage.Store screens []Screen @@ -96,21 +100,39 @@ 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 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 { @@ -135,25 +157,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() } // Delegate to the current screen @@ -205,32 +208,20 @@ 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) *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()}, }) } - - - - - - - - - - - - - - - - - - diff --git a/internal/tui/screen_editor.go b/internal/tui/screen_editor.go index 5ef261a..8f4f03f 100644 --- a/internal/tui/screen_editor.go +++ b/internal/tui/screen_editor.go @@ -132,13 +132,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.executing = true - return func() tea.Msg { - return submitQueryMsg{query: query, screen: queryScreen} + return openQueryMsg{query: query, execute: true} } } @@ -154,5 +149,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..649a0c2 100644 --- a/internal/tui/screen_home.go +++ b/internal/tui/screen_home.go @@ -12,14 +12,13 @@ import ( ) type homeScreen struct { - client *rds.Client queries []*rds.Query table table.Model width, height int } -func newHomeScreen(client *rds.Client) *homeScreen { +func newHomeScreen() *homeScreen { columns := []table.Column{ {Title: "Label", Width: 20}, {Title: "Time", Width: 20}, @@ -32,7 +31,6 @@ func newHomeScreen(client *rds.Client) *homeScreen { ) return &homeScreen{ - client: client, queries: []*rds.Query{}, table: t, } @@ -43,7 +41,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") + @@ -69,18 +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 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, "")} - } + return s, func() tea.Msg { return openEditorMsg{} } + + case tea.KeyCtrlL: + return s, func() tea.Msg { return openSavedQueriesMsg{} } } case queryAddedMsg: diff --git a/internal/tui/screen_query.go b/internal/tui/screen_query.go index d8aa0cf..889eecf 100644 --- a/internal/tui/screen_query.go +++ b/internal/tui/screen_query.go @@ -45,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{}), @@ -53,8 +53,9 @@ func newQueryScreen(query *rds.Query) *queryScreen { ) return &queryScreen{ - query: query, - table: t, + client: client, + query: query, + table: t, } } @@ -78,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 } @@ -102,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, 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 new file mode 100644 index 0000000..d1ebc9c --- /dev/null +++ b/internal/tui/screen_saved.go @@ -0,0 +1,161 @@ +// 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: "Updated", 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") +} + +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 openEditorMsg{sql: q.SQL} + } + } + 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: "Updated", 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.UpdatedAt.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 +}