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