feat: app model with routing, save/load, quit-confirm, status bar
16479115d1ae9159ec2d64bc2b69b9015cc0f5e7
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-27 21:46
parent 15134d87
2 files changed
internal/app/app.go +153 −0
@@ -0,0 +1,153 @@
+// Package app is glint's top-level Bubbletea model. It owns the active mode and
+// routes messages to the editor, picker, or preview sub-models.
+package app
+
+import (
+ "fmt"
+ "os"
+
+ "glint/internal/config"
+ "glint/internal/editor"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Mode selects which sub-view is active.
+type Mode int
+
+const (
+ ModeEditor Mode = iota
+ ModePicker
+ ModePreview
+)
+
+// App is the root model.
+type App struct {
+ mode Mode
+ cfg config.Config
+ editor *editor.Editor
+ path string
+ status string
+ width int
+ height int
+
+ quitArmed bool // true after a dirty Ctrl+Q, awaiting confirm
+}
+
+// New builds an App with an empty editor.
+func New(cfg config.Config) *App {
+ return &App{
+ mode: ModeEditor,
+ cfg: cfg,
+ editor: editor.New(),
+ }
+}
+
+// Load reads a file into the editor and switches to edit mode.
+func (a *App) Load(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ a.editor.SetContent(data)
+ a.path = path
+ a.mode = ModeEditor
+ a.status = path
+ return nil
+}
+
+func (a *App) Init() tea.Cmd { return nil }
+
+// Update routes messages. Global keys are handled first, then mode-specific.
+func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ a.setSize(msg.Width, msg.Height)
+ return a, nil
+ case tea.KeyMsg:
+ return a.handleKey(msg)
+ }
+ return a, nil
+}
+
+func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ // Any key other than a second Ctrl+Q disarms the quit confirmation.
+ if msg.Type != tea.KeyCtrlQ {
+ a.quitArmed = false
+ }
+
+ switch msg.Type {
+ case tea.KeyCtrlQ:
+ if a.editor.Dirty && !a.quitArmed {
+ a.quitArmed = true
+ a.status = "Unsaved changes — Ctrl+Q again to quit"
+ return a, nil
+ }
+ return a, tea.Quit
+ case tea.KeyCtrlS:
+ return a.save()
+ }
+
+ // Mode-specific routing (picker/preview keys wired in later tasks).
+ switch a.mode {
+ case ModeEditor:
+ a.editor.HandleKey(msg)
+ }
+ return a, nil
+}
+
+func (a *App) save() (tea.Model, tea.Cmd) {
+ if a.path == "" {
+ a.status = "No file to save"
+ return a, nil
+ }
+ if err := os.WriteFile(a.path, a.editor.Bytes(), 0o644); err != nil {
+ a.status = "Save failed: " + err.Error()
+ return a, nil
+ }
+ a.editor.Dirty = false
+ a.status = "Saved " + a.path
+ return a, nil
+}
+
+func (a *App) setSize(w, h int) {
+ a.width = w
+ a.height = h
+ a.editor.SetSize(w, h-1) // reserve one row for the status bar
+}
+
+// View renders the active sub-view plus the status bar.
+func (a *App) View() string {
+ var body string
+ switch a.mode {
+ case ModeEditor:
+ body = a.editor.View()
+ default:
+ body = a.editor.View()
+ }
+ return body + a.statusBar()
+}
+
+func (a *App) statusBar() string {
+ bar := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#1a1a24")).
+ Background(lipgloss.Color("#7aa2f7")).
+ Width(maxInt(a.width, 1))
+ dirty := ""
+ if a.editor.Dirty {
+ dirty = " ●"
+ }
+ left := a.status
+ if left == "" {
+ left = "glint"
+ }
+ return bar.Render(fmt.Sprintf(" %s%s ", left, dirty))
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
internal/app/app_test.go +120 −0
@@ -0,0 +1,120 @@
+package app
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "glint/internal/config"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func newApp() *App {
+ return New(config.Default())
+}
+
+func TestLoadReadsFileIntoEditor(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "n.md")
+ os.WriteFile(p, []byte("# hi\nbody"), 0o644)
+
+ a := newApp()
+ if err := a.Load(p); err != nil {
+ t.Fatal(err)
+ }
+ if a.mode != ModeEditor {
+ t.Errorf("mode = %d, want ModeEditor", a.mode)
+ }
+ if string(a.editor.Bytes()) != "# hi\nbody" {
+ t.Errorf("editor content = %q", string(a.editor.Bytes()))
+ }
+}
+
+func TestCtrlSSavesToDisk(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "n.md")
+ os.WriteFile(p, []byte("old"), 0o644)
+
+ a := newApp()
+ a.Load(p)
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}})
+ if !a.editor.Dirty {
+ t.Fatal("expected dirty after edit")
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
+
+ got, _ := os.ReadFile(p)
+ if string(got) != "!old" {
+ t.Errorf("file on disk = %q, want %q", string(got), "!old")
+ }
+ if a.editor.Dirty {
+ t.Error("save should clear dirty")
+ }
+}
+
+func TestTypingRoutesToEditor(t *testing.T) {
+ a := newApp()
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+ if a.editor.Lines[0] != "x" {
+ t.Errorf("editor first line = %q, want x", a.editor.Lines[0])
+ }
+}
+
+func TestCtrlQCleanQuits(t *testing.T) {
+ a := newApp()
+ _, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+ if cmd == nil {
+ t.Error("clean buffer Ctrl+Q should return a quit command")
+ }
+}
+
+func TestCtrlQDirtyNeedsConfirm(t *testing.T) {
+ a := newApp()
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+ _, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+ if cmd != nil {
+ t.Error("first dirty Ctrl+Q should not quit")
+ }
+ _, cmd = a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+ if cmd == nil {
+ t.Error("second Ctrl+Q should quit")
+ }
+}
+
+func TestWindowSizePropagates(t *testing.T) {
+ a := newApp()
+ a.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
+ if a.editor.Width != 100 {
+ t.Errorf("editor width = %d, want 100", a.editor.Width)
+ }
+ if a.editor.Height != 29 { // minus status bar
+ t.Errorf("editor height = %d, want 29", a.editor.Height)
+ }
+}
+
+func TestCtrlQDirtyDisarmedByOtherKey(t *testing.T) {
+ a := newApp()
+ // Make it dirty
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+ // First Ctrl+Q arms (does not quit)
+ _, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+ if cmd != nil {
+ t.Error("first dirty Ctrl+Q should arm, not quit")
+ }
+ // Press another key — this should disarm
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+ // Next Ctrl+Q must ARM AGAIN, not quit
+ _, cmd = a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+ if cmd != nil {
+ t.Error("Ctrl+Q after disarm should re-arm, not quit")
+ }
+}
+
+func TestCtrlSNoPathSetsStatus(t *testing.T) {
+ a := newApp()
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
+ if a.status != "No file to save" {
+ t.Errorf("status = %q, want %q", a.status, "No file to save")
+ }
+}