Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions internal/storage/doc.go
Original file line number Diff line number Diff line change
@@ -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
77 changes: 77 additions & 0 deletions internal/storage/queries.go
Original file line number Diff line number Diff line change
@@ -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
}

91 changes: 91 additions & 0 deletions internal/storage/queries_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
117 changes: 54 additions & 63 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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()},
})
}


















9 changes: 1 addition & 8 deletions internal/tui/screen_editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
}

Expand All @@ -154,5 +149,3 @@ func (s *editorScreen) openExternalEditor() tea.Cmd {
return editorFinishedMsg{editor: editor, err: err}
})
}


Loading