▍ humdrum codex / glint v1.1.2
license AGPL-3.0

feat: route naming dialog through app modes

76b76232f95142121c24d9bda101fda5dadf7de6
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-30 19:28

parent 488500ee

feat: route naming dialog through app modes

ModeNamePrompt replaces ModeSaveAs; Ctrl+N/Ctrl+B and F2 open the
centered dialog, Esc restores the previous mode. Confirm is stubbed
until the next task (TASK-046).

6 files changed

internal/app/app.go +28 −74
@@ -12,6 +12,7 @@ 	"strings"
 	"time"
 
 	"glint/internal/config"
+	"glint/internal/dialog"
 	"glint/internal/editor"
 	"glint/internal/export"
 	"glint/internal/grammar"
@@ -35,7 +36,7 @@ const (
 	ModeEditor Mode = iota
 	ModePicker
 	ModePreview
-	ModeSaveAs
+	ModeNamePrompt // centered dialog naming a new or renamed file (TASK-046)
 	ModeFind
 	ModeHelp
 	ModeGotoLine
@@ -69,7 +70,10 @@ 	theme      theme.Theme
 	editor     *editor.Editor
 	preview    *preview.Model
 	picker     *picker.Model
-	saveInput  textinput.Model            // one-line "save as" prompt for unnamed buffers
+	dialog     *dialog.Model              // centered new-file / rename prompt (TASK-046)
+	dialogKind namingKind                 // what the open dialog will do on confirm
+	dialogDir  string                     // directory a new name resolves under
+	prevMode   Mode                       // mode to restore when the dialog is cancelled
 	findInput  textinput.Model            // one-line in-document find prompt (TASK-007)
 	gotoInput  textinput.Model            // one-line go-to-line prompt (TASK-012)
 	helpView   viewport.Model             // scrollable keybind overlay (TASK-011)
@@ -104,9 +108,6 @@ func New(cfg config.Config) *App {
 	th := theme.Resolve(cfg.Theme)
 	ed := editor.New()
 	ed.SetTheme(th)
-	ti := textinput.New()
-	ti.Prompt = "save as › "
-	ti.Placeholder = "name…"
 	fi := textinput.New()
 	fi.Prompt = "find › "
 	fi.Placeholder = "text…"
@@ -120,7 +121,6 @@ 		mode:      ModeEditor,
 		cfg:       cfg,
 		theme:     th,
 		editor:    ed,
-		saveInput: ti,
 		findInput: fi,
 		gotoInput: gi,
 		helpView:  hv,
@@ -128,6 +128,7 @@ 		cursorMem: map[string]editor.Position{},
 	}
 	a.preview = preview.New(a.glamourStyle())
 	a.preview.SetColors(previewColors(th))
+	a.dialog = dialog.New(th)
 	a.initSpell()
 	return a
 }
@@ -414,9 +415,14 @@ 	case tea.KeyCtrlG:
 		return a.openFind()
 	case tea.KeyCtrlL:
 		return a.openGoto()
+	case tea.KeyF2:
+		return a.renameFile()
 	case tea.KeyCtrlUnderscore: // Ctrl+/ toggles the help overlay
 		return a.toggleHelp()
 	case tea.KeyEsc:
+		if a.mode == ModeNamePrompt {
+			return a.cancelName()
+		}
 		if a.mode == ModeFind {
 			a.editor.ClearFind()
 		} else if a.editor.HasSelection() {
@@ -430,13 +436,8 @@ 	switch a.mode {
 	case ModeEditor:
 		a.editor.HandleKey(msg)
 		return a, a.grammarChanged() // debounced re-lint after the edit settles
-	case ModeSaveAs:
-		if msg.Type == tea.KeyEnter {
-			return a.saveAs()
-		}
-		var cmd tea.Cmd
-		a.saveInput, cmd = a.saveInput.Update(msg)
-		return a, cmd
+	case ModeNamePrompt:
+		return a.handleNameKey(msg)
 	case ModeFind:
 		return a.handleFindKey(msg)
 	case ModeGotoLine:
@@ -466,11 +467,11 @@ 	return a, nil
 }
 
 func (a *App) save() (tea.Model, tea.Cmd) {
-	if a.mode == ModeSaveAs {
-		return a.saveAs() // Ctrl+S confirms an open save-as prompt
+	if a.mode == ModeNamePrompt {
+		return a.confirmName() // Ctrl+S confirms an open naming prompt
 	}
 	if a.path == "" {
-		return a.promptSaveAs() // unnamed buffer → ask for a name
+		return a.nameCurrentBuffer()
 	}
 	if err := os.WriteFile(a.path, a.editor.Bytes(), 0o644); err != nil {
 		a.status = "Save failed: " + err.Error()
@@ -560,43 +561,6 @@ 	}
 	return export.CopyRichText(string(data))
 }
 
-// promptSaveAs opens the one-line save-as prompt for an unnamed buffer.
-func (a *App) promptSaveAs() (tea.Model, tea.Cmd) {
-	a.saveInput.SetValue("")
-	a.saveInput.Focus()
-	a.mode = ModeSaveAs
-	a.status = "Save as — type a name, Enter to save, Esc to cancel"
-	return a, nil
-}
-
-// saveAs writes the unnamed buffer to a name typed at the prompt, resolved under
-// the inbox directory, then binds the buffer to that path.
-func (a *App) saveAs() (tea.Model, tea.Cmd) {
-	root := a.saveDir
-	if root == "" {
-		root = a.cfg.InboxRoot()
-	}
-	p := picker.NewNotePath(root, a.saveInput.Value())
-	if p == "" {
-		a.status = "Type a name first"
-		return a, nil
-	}
-	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
-		a.status = "Save dir failed: " + err.Error()
-		return a, nil
-	}
-	if err := os.WriteFile(p, a.editor.Bytes(), 0o644); err != nil {
-		a.status = "Save failed: " + err.Error()
-		return a, nil
-	}
-	a.path = p
-	a.editor.Dirty = false
-	a.mode = ModeEditor
-	a.status = "Saved " + p
-	a.grammarOpen() // the buffer got a real name/URI; reopen it with harper
-	return a, nil
-}
-
 // openFind opens the in-document find bar over the current editor buffer.
 // Ctrl+F is already the file picker, so find is Ctrl+G.
 func (a *App) openFind() (tea.Model, tea.Cmd) {
@@ -678,16 +642,13 @@ 	}
 	return fmt.Sprintf("%d matches", a.editor.FindCount())
 }
 
-// newFile is the Ctrl+N / Ctrl+I handler: start a new note in dir. From the
-// picker with a typed query it creates dir/<query>.md; otherwise it opens a
-// blank buffer whose save-as targets dir (confirming discard if the editor is
-// dirty, keyed by pend so re-pressing the same key confirms).
+// newFile is the Ctrl+N / Ctrl+B handler: open the naming dialog for a new note
+// in dir. From the picker the typed query prefills the dialog. Outside the
+// picker a dirty buffer must confirm the discard first (keyed by pend, so
+// re-pressing the same key confirms).
 func (a *App) newFile(dir string, pend pendingDiscard) (tea.Model, tea.Cmd) {
 	if a.mode == ModePicker {
-		if q := strings.TrimSpace(a.picker.Query()); q != "" {
-			return a.openNoteAt(picker.NewNotePath(dir, q))
-		}
-		a.startBlankIn(dir)
+		a.openNamePrompt(namingNew, dir, strings.TrimSpace(a.picker.Query()))
 		return a, nil
 	}
 	if a.editor.Dirty && a.pending != pend {
@@ -696,7 +657,7 @@ 		a.status = "Unsaved changes — press again to discard"
 		return a, nil
 	}
 	a.pending = discardNone
-	a.startBlankIn(dir)
+	a.openNamePrompt(namingNew, dir, "")
 	return a, nil
 }
 
@@ -840,6 +801,7 @@ 	a.theme = theme.Next(a.theme.Name)
 	a.editor.SetTheme(a.theme)
 	a.preview.SetStyle(a.glamourStyle())
 	a.preview.SetColors(previewColors(a.theme))
+	a.dialog.SetTheme(a.theme)
 	// Re-render an open preview so its glamour style follows the new theme
 	// (otherwise it keeps the old light/dark block until the next toggle).
 	if a.mode == ModePreview {
@@ -922,6 +884,7 @@ 		textRows = 1
 	}
 	a.editor.SetSize(cw, textRows)
 	a.preview.SetSize(cw, textRows)
+	a.dialog.SetWidth(cw)
 	// The help overlay is a bordered box on the canvas: fit it inside the column
 	// (minus the 1-cell border each side) and the text rows (minus border + a
 	// title/footer line each).
@@ -999,13 +962,13 @@ 	case ModeHelp:
 		body = a.helpOverlay()
 	case ModePreview:
 		body = a.preview.View()
+	case ModeNamePrompt:
+		body = a.dialog.View()
 	default:
-		body = a.editor.View() // editor stays visible beneath the save-as prompt
+		body = a.editor.View() // editor stays visible beneath overlays
 	}
 	bottom := a.statusBar()
 	switch a.mode {
-	case ModeSaveAs:
-		bottom = a.saveBar()
 	case ModeFind:
 		bottom = a.findBar()
 	case ModeGotoLine:
@@ -1051,15 +1014,6 @@ 		Foreground(a.theme.StatusFg).
 		Background(a.theme.StatusBg).
 		Width(maxInt(a.width, 1))
 	return bar.Render(" " + a.gotoInput.View() + " ")
-}
-
-// saveBar renders the save-as prompt as a themed full-width bottom bar.
-func (a *App) saveBar() string {
-	bar := lipgloss.NewStyle().
-		Foreground(a.theme.StatusFg).
-		Background(a.theme.StatusBg).
-		Width(maxInt(a.width, 1))
-	return bar.Render(" " + a.saveInput.View() + " ")
 }
 
 // paintCanvas centers body in the theme's paper: a top pad, then each body line
internal/app/app_test.go +4 −4
@@ -132,8 +132,8 @@
 func TestCtrlSNoPathOpensSaveAsPrompt(t *testing.T) {
 	a := newApp()
 	a.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
-	if a.mode != ModeSaveAs {
-		t.Errorf("Ctrl+S on unnamed buffer: mode = %d, want ModeSaveAs", a.mode)
+	if a.mode != ModeNamePrompt {
+		t.Errorf("Ctrl+S on unnamed buffer: mode = %d, want ModeNamePrompt", a.mode)
 	}
 }
 
@@ -444,8 +444,8 @@ 	a.StartNew("") // blank unnamed buffer
 	a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")})
 
 	a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) // pathless → save-as prompt
-	if a.mode != ModeSaveAs {
-		t.Fatalf("Ctrl+S on pathless buffer: mode = %d, want ModeSaveAs", a.mode)
+	if a.mode != ModeNamePrompt {
+		t.Fatalf("Ctrl+S on pathless buffer: mode = %d, want ModeNamePrompt", a.mode)
 	}
 	a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("idea")})
 	a.Update(tea.KeyMsg{Type: tea.KeyEnter}) // confirm
internal/app/header.go +1 −1
@@ -30,7 +30,7 @@ // and the overlays that keep it visible beneath them. The picker has its own
 // layout, help replaces the body, and preview draws its own title bar.
 func (a *App) showHeader() bool {
 	switch a.mode {
-	case ModeEditor, ModeFind, ModeGotoLine, ModeSpell:
+	case ModeEditor, ModeFind, ModeGotoLine, ModeSpell, ModeNamePrompt:
 		return true
 	}
 	return false
internal/app/header_test.go +1 −1
@@ -118,7 +118,7 @@
 func TestHeaderShownInOverlayModes(t *testing.T) {
 	a := newApp()
 	a.path = "/tmp/notes/shown.md"
-	for _, m := range []Mode{ModeEditor, ModeFind, ModeGotoLine, ModeSpell} {
+	for _, m := range []Mode{ModeEditor, ModeFind, ModeGotoLine, ModeSpell, ModeNamePrompt} {
 		a.mode = m
 		if !a.showHeader() {
 			t.Errorf("showHeader() = false in mode %d, want true", m)
internal/app/naming.go +95 −0
@@ -0,0 +1,95 @@
+package app
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	"glint/internal/dialog"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+// namingKind is what an open naming dialog will do when confirmed.
+type namingKind int
+
+const (
+	namingNew    namingKind = iota // create dialogDir/<name>.md and open it
+	namingRename                   // rename the current file to <name>.md
+)
+
+// openNamePrompt shows the centered naming dialog. dir is the folder a new name
+// resolves under; prefill seeds the input (the picker query, or the current
+// basename for a rename). Cancelling returns to whatever mode was active.
+func (a *App) openNamePrompt(kind namingKind, dir, prefill string) {
+	title := "New note"
+	if kind == namingRename {
+		title = "Rename"
+	}
+	a.prevMode = a.mode
+	a.dialogKind = kind
+	a.dialogDir = dir
+	a.dialog.SetWidth(a.contentWidth())
+	a.dialog.Open(title, a.dirLabel(dir), prefill)
+	a.mode = ModeNamePrompt
+	a.status = ""
+}
+
+// renameFile is the F2 handler: rename the open file, or name a buffer that has
+// no path yet. Only meaningful over the editor.
+func (a *App) renameFile() (tea.Model, tea.Cmd) {
+	if a.mode == ModePicker || a.mode == ModeNamePrompt {
+		return a, nil
+	}
+	if a.path == "" {
+		return a.nameCurrentBuffer()
+	}
+	base := filepath.Base(a.path)
+	a.openNamePrompt(namingRename, filepath.Dir(a.path), strings.TrimSuffix(base, filepath.Ext(base)))
+	return a, nil
+}
+
+// nameCurrentBuffer names a pathless buffer, then writes it — the job the
+// bottom-bar save-as prompt used to do.
+func (a *App) nameCurrentBuffer() (tea.Model, tea.Cmd) {
+	dir := a.saveDir
+	if dir == "" {
+		dir = a.cfg.InboxRoot()
+	}
+	a.openNamePrompt(namingRename, dir, "")
+	return a, nil
+}
+
+// handleNameKey routes a key into the dialog and acts on what it reports.
+func (a *App) handleNameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	res, cmd := a.dialog.Update(msg)
+	switch res {
+	case dialog.ResultConfirm:
+		return a.confirmName()
+	case dialog.ResultCancel:
+		return a.cancelName()
+	}
+	return a, cmd
+}
+
+// cancelName closes the dialog and restores the mode it opened over, writing
+// nothing.
+func (a *App) cancelName() (tea.Model, tea.Cmd) {
+	a.mode = a.prevMode
+	a.status = ""
+	return a, nil
+}
+
+// dirLabel renders dir for the dialog's hint: $HOME collapsed to ~, one
+// trailing separator so it reads as a folder.
+func (a *App) dirLabel(dir string) string {
+	if home, err := os.UserHomeDir(); err == nil && home != "" && strings.HasPrefix(dir, home) {
+		dir = "~" + strings.TrimPrefix(dir, home)
+	}
+	return strings.TrimSuffix(dir, string(filepath.Separator)) + string(filepath.Separator)
+}
+
+// confirmName applies the dialog's name. Implemented in the next task.
+func (a *App) confirmName() (tea.Model, tea.Cmd) {
+	return a.cancelName()
+}
internal/app/naming_test.go +112 −0
@@ -0,0 +1,112 @@
+package app
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func typeInto(a *App, s string) {
+	for _, r := range s {
+		a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+	}
+}
+
+func TestCtrlNOpensNamePrompt(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	if a.mode != ModeNamePrompt {
+		t.Fatalf("mode = %d, want ModeNamePrompt", a.mode)
+	}
+	if a.dialog.Title() != "New note" {
+		t.Errorf("dialog title = %q, want 'New note'", a.dialog.Title())
+	}
+}
+
+func TestNamePromptShowsTargetDir(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.openNamePrompt(namingNew, dir, "")
+	if !strings.Contains(a.View(), filepath.Base(dir)) {
+		t.Errorf("View() does not show the target dir %q:\n%s", dir, a.View())
+	}
+}
+
+func TestEscCancelsBackToPreviousMode(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "open.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.Load(p)
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+	if a.mode != ModeEditor {
+		t.Errorf("mode = %d after Esc, want ModeEditor", a.mode)
+	}
+	if a.path != p {
+		t.Errorf("path = %q after Esc, want the file we were editing (%q)", a.path, p)
+	}
+	entries, _ := os.ReadDir(dir)
+	if len(entries) != 1 {
+		t.Errorf("Esc wrote %d entries to disk, want 1 (the pre-existing file)", len(entries))
+	}
+}
+
+func TestDirtyBufferConfirmsDiscardBeforePrompt(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.editor.SetContent([]byte("x"))
+	a.editor.Dirty = true
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	if a.mode == ModeNamePrompt {
+		t.Fatal("first Ctrl+N on a dirty buffer opened the prompt; want a discard confirmation")
+	}
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	if a.mode != ModeNamePrompt {
+		t.Errorf("second Ctrl+N: mode = %d, want ModeNamePrompt", a.mode)
+	}
+}
+
+func TestPickerCtrlNPrefillsQuery(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	if err := a.StartPickerIn(dir); err != nil {
+		t.Fatal(err)
+	}
+	typeInto(a, "draft")
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	if a.mode != ModeNamePrompt {
+		t.Fatalf("mode = %d, want ModeNamePrompt", a.mode)
+	}
+	if a.dialog.Value() != "draft" {
+		t.Errorf("dialog prefill = %q, want draft", a.dialog.Value())
+	}
+}
+
+func TestF2OpensRenamePrefilled(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "before.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.Load(p)
+	a.Update(tea.KeyMsg{Type: tea.KeyF2})
+	if a.mode != ModeNamePrompt {
+		t.Fatalf("mode = %d, want ModeNamePrompt", a.mode)
+	}
+	if a.dialog.Title() != "Rename" {
+		t.Errorf("dialog title = %q, want Rename", a.dialog.Title())
+	}
+	if a.dialog.Value() != "before" {
+		t.Errorf("dialog prefill = %q, want before", a.dialog.Value())
+	}
+}