fix: confirm-then-discard on open-while-dirty, size picker on resize, status-bar layout
4b56fb790d0bcee804f512229156c10da7fa20dc
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-27 22:50
parent 87bf7725
fix: confirm-then-discard on open-while-dirty, size picker on resize, status-bar layout - Add pendingDiscard guard (Ctrl+P / Ctrl+D): dirty buffer requires a second press to confirm discarding unsaved edits; any other key disarms the pending state, mirroring the existing quitArmed pattern - setSize now forwards dimensions to picker when non-nil, preventing height=-1 (single match row) when WindowSizeMsg arrives before the first picker open - View normalises body to end with exactly one newline before appending the status bar, fixing concatenation onto the last content row in preview/picker Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 files changed
internal/app/app.go +35 −1
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
"glint/internal/config"
@@ -26,6 +27,15 @@ ModePicker
ModePreview
)
+// pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
+type pendingDiscard int
+
+const (
+ discardNone pendingDiscard = iota
+ discardPicker
+ discardDaily
+)
+
// App is the root model.
type App struct {
mode Mode
@@ -38,7 +48,8 @@ status string
width int
height int
- quitArmed bool // true after a dirty Ctrl+Q, awaiting confirm
+ quitArmed bool // true after a dirty Ctrl+Q, awaiting confirm
+ pending pendingDiscard // armed open-while-dirty confirmation
}
// New builds an App with an empty editor.
@@ -99,6 +110,11 @@ 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
+ }
+ // Disarm pending-discard unless the same action is being re-pressed.
+ if !(msg.Type == tea.KeyCtrlP && a.pending == discardPicker) &&
+ !(msg.Type == tea.KeyCtrlD && a.pending == discardDaily) {
+ a.pending = discardNone
}
switch msg.Type {
@@ -114,8 +130,20 @@ return a.save()
case tea.KeyCtrlR:
return a.togglePreview()
case tea.KeyCtrlP:
+ if a.editor.Dirty && a.pending != discardPicker {
+ a.pending = discardPicker
+ a.status = "Unsaved changes — Ctrl+P again to discard"
+ return a, nil
+ }
+ a.pending = discardNone
return a.openPicker()
case tea.KeyCtrlD:
+ if a.editor.Dirty && a.pending != discardDaily {
+ a.pending = discardDaily
+ a.status = "Unsaved changes — Ctrl+D again to discard"
+ return a, nil
+ }
+ a.pending = discardNone
return a.openDaily()
case tea.KeyEsc:
a.mode = ModeEditor
@@ -174,6 +202,9 @@ a.width = w
a.height = h
a.editor.SetSize(w, h-1) // reserve one row for the status bar
a.preview.SetSize(w, h-1)
+ if a.picker != nil {
+ a.picker.SetSize(w, h-1)
+ }
}
// openPicker builds a fresh picker over the vault and switches to it.
@@ -218,6 +249,9 @@ case ModePicker:
body = a.picker.View()
default:
body = a.editor.View()
+ }
+ if !strings.HasSuffix(body, "\n") {
+ body += "\n"
}
return body + a.statusBar()
}
internal/app/app_test.go +76 −0
@@ -3,6 +3,7 @@
import (
"os"
"path/filepath"
+ "strings"
"testing"
"glint/internal/config"
@@ -219,3 +220,78 @@ if _, err := os.Stat(a.path); err != nil {
t.Errorf("daily file should exist: %v", err)
}
}
+
+func TestCtrlPDirtyNeedsConfirm(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644)
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ // Make dirty
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+ // First Ctrl+P — should arm, not open picker
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
+ if a.mode != ModeEditor {
+ t.Errorf("first Ctrl+P on dirty editor: mode = %d, want ModeEditor", a.mode)
+ }
+ if !strings.Contains(a.status, "discard") {
+ t.Errorf("first Ctrl+P on dirty editor: status = %q, want it to contain 'discard'", a.status)
+ }
+ // Second Ctrl+P — should open picker
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
+ if a.mode != ModePicker {
+ t.Errorf("second Ctrl+P: mode = %d, want ModePicker", a.mode)
+ }
+}
+
+func TestCtrlPDirtyDisarmedByOtherKey(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644)
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ // Make dirty
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+ // First Ctrl+P — arms
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
+ // Press a different key — disarms pending
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+ // Second Ctrl+P — should re-arm (not open) because pending was cleared
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
+ if a.mode != ModeEditor {
+ t.Errorf("Ctrl+P after disarm should re-arm, not open picker: mode = %d", a.mode)
+ }
+}
+
+func TestCtrlDDirtyNeedsConfirm(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ cfg.DailySubdir = "Daily"
+ cfg.DailyFormat = "2006-01-02"
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ // Make dirty
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+ // First Ctrl+D — should arm, not open daily
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
+ if a.mode != ModeEditor {
+ t.Errorf("first Ctrl+D on dirty editor: mode = %d, want ModeEditor", a.mode)
+ }
+ if !strings.Contains(a.status, "discard") {
+ t.Errorf("first Ctrl+D on dirty editor: status = %q, want it to contain 'discard'", a.status)
+ }
+ // Second Ctrl+D — should open daily
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
+ if a.mode != ModeEditor {
+ t.Errorf("second Ctrl+D: mode = %d, want ModeEditor", a.mode)
+ }
+ if a.path == "" {
+ t.Error("second Ctrl+D: a.path should be set to the daily note path")
+ }
+ if _, err := os.Stat(a.path); err != nil {
+ t.Errorf("second Ctrl+D: daily file should exist on disk: %v", err)
+ }
+}