▍ humdrum codex / glint v1.0.2

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