fix: reentrant Ctrl+N/Ctrl+B no longer strand the naming dialog
a4c0febaebb63986eea6267e6503870b742edf04
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-30 19:36
parent 143ab56f
fix: reentrant Ctrl+N/Ctrl+B no longer strand the naming dialog openNamePrompt no longer overwrites prevMode when the dialog is already open, and newFile ignores a re-press while naming is in progress instead of retargeting dialogDir/dialogKind mid-typing. Also tighten dirLabel's $HOME collapse to a separator boundary so a sibling directory sharing the prefix (e.g. /Users/foobar under /Users/foo) doesn't render with a false ~ (TASK-046).
3 files changed
internal/app/app.go +3 −0
@@ -647,6 +647,9 @@ // 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 == ModeNamePrompt {
+ return a, nil // already naming something; don't retarget mid-prompt
+ }
if a.mode == ModePicker {
a.openNamePrompt(namingNew, dir, strings.TrimSpace(a.picker.Query()))
return a, nil
internal/app/naming.go +17 −2
@@ -26,7 +26,12 @@ title := "New note"
if kind == namingRename {
title = "Rename"
}
- a.prevMode = a.mode
+ // Re-entering the dialog (e.g. a second Ctrl+N while it's already open)
+ // must not make the dialog its own return mode — that would strand Esc
+ // and confirm in ModeNamePrompt forever.
+ if a.mode != ModeNamePrompt {
+ a.prevMode = a.mode
+ }
a.dialogKind = kind
a.dialogDir = dir
a.dialog.SetWidth(a.contentWidth())
@@ -83,10 +88,20 @@
// 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) {
+ if home, err := os.UserHomeDir(); err == nil && home != "" && isHomeOrChild(dir, home) {
dir = "~" + strings.TrimPrefix(dir, home)
}
return strings.TrimSuffix(dir, string(filepath.Separator)) + string(filepath.Separator)
+}
+
+// isHomeOrChild reports whether dir is home itself or a path under it,
+// requiring the match to end at a path separator rather than merely sharing a
+// string prefix (so /Users/foobar does not collapse under home=/Users/foo).
+func isHomeOrChild(dir, home string) bool {
+ if dir == home {
+ return true
+ }
+ return strings.HasPrefix(dir, home+string(filepath.Separator))
}
// confirmName applies the dialog's name. Implemented in the next task.
internal/app/naming_test.go +28 −0
@@ -110,3 +110,31 @@ if a.dialog.Value() != "before" {
t.Errorf("dialog prefill = %q, want before", a.dialog.Value())
}
}
+
+func TestReentrantCtrlNDoesNotTrapTheDialog(t *testing.T) {
+ dir := t.TempDir()
+ a := newApp()
+ a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+ a.saveDir = dir
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) // re-press while the prompt is open
+ if a.prevMode == ModeNamePrompt {
+ t.Fatal("prevMode became ModeNamePrompt; Esc would be a no-op")
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if a.mode == ModeNamePrompt {
+ t.Error("Esc left the app in ModeNamePrompt; the dialog is inescapable")
+ }
+}
+
+func TestDirLabelDoesNotCollapseSiblingSharingHomePrefix(t *testing.T) {
+ home, err := os.UserHomeDir()
+ if err != nil || home == "" {
+ t.Skip("no $HOME to test against")
+ }
+ a := newApp()
+ sibling := home + "bar" // shares home's string prefix but is not under it
+ if got := a.dirLabel(sibling); strings.HasPrefix(got, "~") {
+ t.Errorf("dirLabel(%q) = %q, want no ~ collapse for a sibling directory", sibling, got)
+ }
+}