feat: Ctrl+N creates a note from the picker query
d0976caaf69dabe2d30074fb0edc6d3ca1e02a68
humdrum <me@humdrum.me> · 2026-06-28 09:48
parent 65dd4e62
4 files changed
internal/app/app.go +27 −1
@@ -165,13 +165,16 @@ a.editor.HandleKey(msg)
case ModePreview:
return a, a.preview.Update(msg)
case ModePicker:
- if msg.Type == tea.KeyEnter {
+ switch msg.Type {
+ case tea.KeyEnter:
if sel := a.picker.Selected(); sel != "" {
if err := a.Load(sel); err != nil {
a.status = "Open failed: " + err.Error()
}
}
return a, nil
+ case tea.KeyCtrlN:
+ return a.newNote()
}
return a, a.picker.Update(msg)
}
@@ -266,6 +269,29 @@ }
}
if err := a.Load(path); err != nil {
a.status = "Daily open failed: " + err.Error()
+ }
+ return a, nil
+}
+
+// newNote creates a note named after the picker query and opens it.
+func (a *App) newNote() (tea.Model, tea.Cmd) {
+ p := picker.NewNotePath(a.cfg.VaultDir, a.picker.Query())
+ if p == "" {
+ a.status = "Type a name first"
+ return a, nil
+ }
+ if _, err := os.Stat(p); os.IsNotExist(err) {
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ a.status = "New note dir failed: " + err.Error()
+ return a, nil
+ }
+ if err := os.WriteFile(p, []byte{}, 0o644); err != nil {
+ a.status = "New note failed: " + err.Error()
+ return a, nil
+ }
+ }
+ if err := a.Load(p); err != nil {
+ a.status = "Open failed: " + err.Error()
}
return a, nil
}
internal/app/app_test.go +26 −0
@@ -313,3 +313,29 @@ if a.theme.Name != "charm" {
t.Errorf("after two Ctrl+T = %q, want charm", a.theme.Name)
}
}
+
+func TestCtrlNCreatesNoteFromQuery(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) // open picker
+ if a.mode != ModePicker {
+ t.Fatalf("expected picker mode")
+ }
+ // Type a name into the picker query.
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ideas")})
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+
+ if a.mode != ModeEditor {
+ t.Errorf("Ctrl+N should open the new note in the editor, mode = %d", a.mode)
+ }
+ want := filepath.Join(dir, "ideas.md")
+ if a.path != want {
+ t.Errorf("a.path = %q, want %q", a.path, want)
+ }
+ if _, err := os.Stat(want); err != nil {
+ t.Errorf("new note should exist on disk: %v", err)
+ }
+}
internal/picker/picker.go +14 −0
@@ -295,3 +295,17 @@ return 0, false
}
return score, true
}
+
+// NewNotePath builds an absolute note path from a query under root. It trims
+// surrounding whitespace, appends ".md" when absent, and supports "Folder/Name".
+// A blank query yields "".
+func NewNotePath(root, query string) string {
+ rel := strings.TrimSpace(query)
+ if rel == "" {
+ return ""
+ }
+ if !strings.EqualFold(filepath.Ext(rel), ".md") {
+ rel += ".md"
+ }
+ return filepath.Join(root, rel)
+}
internal/picker/picker_test.go +18 −0
@@ -136,3 +136,21 @@ if !strings.Contains(view, "Heading") {
t.Errorf("preview pane should render the selected file's heading; view:\n%s", view)
}
}
+
+func TestNewNotePath(t *testing.T) {
+ root := "/v"
+ cases := map[string]string{
+ "foo": filepath.Join("/v", "foo.md"),
+ "Folder/Bar": filepath.Join("/v", "Folder", "Bar.md"),
+ "x.md": filepath.Join("/v", "x.md"),
+ " spaced ": filepath.Join("/v", "spaced.md"),
+ }
+ for q, want := range cases {
+ if got := NewNotePath(root, q); got != want {
+ t.Errorf("NewNotePath(%q) = %q, want %q", q, got, want)
+ }
+ }
+ if NewNotePath(root, " ") != "" {
+ t.Error("blank query should yield empty path")
+ }
+}