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

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