▍ humdrum codex / glint v1.0.2

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")
+	}
+}