▍ humdrum codex / glint v1.0.2

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