feat: glint new — fresh scratch doc + save-as to a configurable inbox
39bd908355006ec750b0191e611b6d668dee0bd1
humdrum <me@humdrum.me> · 2026-06-28 15:50
parent d919368d
feat: glint new — fresh scratch doc + save-as to a configurable inbox Adds a way to start a blank markdown document from anywhere: - `glint new` opens a blank unnamed buffer; `glint new <name>` creates and opens <inbox>/<name>.md. - Ctrl+N inside the editor starts a fresh blank doc (confirm-then-discard if dirty, like Ctrl+P/Ctrl+D); Ctrl+N in the picker still makes a note from the query. - Ctrl+S on an unnamed buffer opens a one-line save-as prompt; the typed name resolves under the inbox (Folder/Name supported, .md appended), writes, and binds the buffer. - New config key inbox_dir (default "" = vault root) sets where new notes land; relative paths resolve under the vault, absolute paths as-is. Tests cover the inbox resolver, the save-as flow, editor Ctrl+N dirty-confirm, and both StartNew paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5 files changed
internal/app/app.go +122 −16
@@ -16,6 +16,7 @@ "glint/internal/picker"
"glint/internal/preview"
"glint/internal/theme"
+ "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
@@ -27,6 +28,7 @@ const (
ModeEditor Mode = iota
ModePicker
ModePreview
+ ModeSaveAs
)
// pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
@@ -36,6 +38,7 @@ const (
discardNone pendingDiscard = iota
discardPicker
discardDaily
+ discardNew
)
// Canvas layout: a centered, percentage-width text column with a little top air.
@@ -51,13 +54,14 @@ type App struct {
mode Mode
cfg config.Config
theme theme.Theme
- editor *editor.Editor
- preview *preview.Model
- picker *picker.Model
- path string
- status string
- width int
- height int
+ editor *editor.Editor
+ preview *preview.Model
+ picker *picker.Model
+ saveInput textinput.Model // one-line "save as" prompt for unnamed buffers
+ path string
+ status string
+ width int
+ height int
quitArmed bool // true after a dirty Ctrl+Q, awaiting confirm
pending pendingDiscard // armed open-while-dirty confirmation
@@ -68,11 +72,15 @@ 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…"
a := &App{
- mode: ModeEditor,
- cfg: cfg,
- theme: th,
- editor: ed,
+ mode: ModeEditor,
+ cfg: cfg,
+ theme: th,
+ editor: ed,
+ saveInput: ti,
}
a.preview = preview.New(a.glamourStyle())
return a
@@ -129,7 +137,8 @@ a.quitArmed = false
}
// Disarm pending-discard unless the same action is being re-pressed.
if !(msg.Type == tea.KeyCtrlP && a.pending == discardPicker) &&
- !(msg.Type == tea.KeyCtrlD && a.pending == discardDaily) {
+ !(msg.Type == tea.KeyCtrlD && a.pending == discardDaily) &&
+ !(msg.Type == tea.KeyCtrlN && a.pending == discardNew) {
a.pending = discardNone
}
@@ -170,7 +179,17 @@ }
switch a.mode {
case ModeEditor:
+ if msg.Type == tea.KeyCtrlN {
+ return a.newBlank()
+ }
a.editor.HandleKey(msg)
+ 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 ModePreview:
return a, a.preview.Update(msg)
case ModePicker:
@@ -191,9 +210,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.path == "" {
- a.status = "No file to save"
- return a, nil
+ return a.promptSaveAs() // unnamed buffer → ask for a name
}
if err := os.WriteFile(a.path, a.editor.Bytes(), 0o644); err != nil {
a.status = "Save failed: " + err.Error()
@@ -204,6 +225,78 @@ a.status = "Saved " + a.path
return a, nil
}
+// 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) {
+ p := picker.NewNotePath(a.cfg.InboxRoot(), 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
+ return a, nil
+}
+
+// newBlank starts a fresh unnamed buffer, confirming discard if the current
+// buffer is dirty (a second Ctrl+N discards).
+func (a *App) newBlank() (tea.Model, tea.Cmd) {
+ if a.editor.Dirty && a.pending != discardNew {
+ a.pending = discardNew
+ a.status = "Unsaved changes — Ctrl+N again to discard"
+ return a, nil
+ }
+ a.pending = discardNone
+ a.startBlank()
+ return a, nil
+}
+
+// startBlank resets to an empty, unnamed editor buffer.
+func (a *App) startBlank() {
+ a.editor.SetContent(nil)
+ a.path = ""
+ a.mode = ModeEditor
+ a.status = "New note"
+}
+
+// StartNew is the entry point for `glint new [name]`: a blank buffer when name
+// is empty, or a new note created under the inbox when a name is given.
+func (a *App) StartNew(name string) error {
+ if strings.TrimSpace(name) == "" {
+ a.startBlank()
+ return nil
+ }
+ p := picker.NewNotePath(a.cfg.InboxRoot(), name)
+ if _, err := os.Stat(p); os.IsNotExist(err) {
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(p, []byte{}, 0o644); err != nil {
+ return err
+ }
+ }
+ return a.Load(p)
+}
+
// glamourStyle is the explicit config override if set, else the theme's style.
func (a *App) glamourStyle() string {
if a.cfg.GlamourStyle != "" {
@@ -352,9 +445,22 @@ var body string
if a.mode == ModePreview {
body = a.preview.View()
} else {
- body = a.editor.View()
+ body = a.editor.View() // editor stays visible beneath the save-as prompt
+ }
+ bottom := a.statusBar()
+ if a.mode == ModeSaveAs {
+ bottom = a.saveBar()
}
- return a.paintCanvas(body) + a.statusBar()
+ return a.paintCanvas(body) + bottom
+}
+
+// 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 +98 −3
@@ -128,11 +128,11 @@ t.Error("Ctrl+Q after disarm should re-arm, not quit")
}
}
-func TestCtrlSNoPathSetsStatus(t *testing.T) {
+func TestCtrlSNoPathOpensSaveAsPrompt(t *testing.T) {
a := newApp()
a.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
- if a.status != "No file to save" {
- t.Errorf("status = %q, want %q", a.status, "No file to save")
+ if a.mode != ModeSaveAs {
+ t.Errorf("Ctrl+S on unnamed buffer: mode = %d, want ModeSaveAs", a.mode)
}
}
@@ -396,3 +396,98 @@ if !found {
t.Errorf("editor content not indented by left margin; view:\n%s", view)
}
}
+
+func TestStartNewBlankBuffer(t *testing.T) {
+ cfg := config.Default()
+ a := New(cfg)
+ if err := a.StartNew(""); err != nil {
+ t.Fatal(err)
+ }
+ if a.mode != ModeEditor {
+ t.Errorf("mode = %d, want ModeEditor", a.mode)
+ }
+ if a.path != "" {
+ t.Errorf("path = %q, want empty (unnamed)", a.path)
+ }
+ if got := string(a.editor.Bytes()); got != "" {
+ t.Errorf("editor content = %q, want empty", got)
+ }
+}
+
+func TestStartNewWithNameLandsInInbox(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ cfg.InboxDir = "Inbox"
+ a := New(cfg)
+ if err := a.StartNew("foo"); err != nil {
+ t.Fatal(err)
+ }
+ want := filepath.Join(dir, "Inbox", "foo.md")
+ if a.path != want {
+ t.Errorf("path = %q, want %q", a.path, want)
+ }
+ if _, err := os.Stat(want); err != nil {
+ t.Errorf("inbox note not created: %v", err)
+ }
+}
+
+func TestSaveAsPromptOnPathlessBuffer(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ cfg.InboxDir = "" // vault root
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ 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)
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("idea")})
+ a.Update(tea.KeyMsg{Type: tea.KeyEnter}) // confirm
+
+ want := filepath.Join(dir, "idea.md")
+ if a.path != want {
+ t.Errorf("path = %q, want %q", a.path, want)
+ }
+ data, err := os.ReadFile(want)
+ if err != nil {
+ t.Fatalf("file not written: %v", err)
+ }
+ if string(data) != "hello" {
+ t.Errorf("saved content = %q, want hello", string(data))
+ }
+ if a.editor.Dirty {
+ t.Error("buffer should be clean after save")
+ }
+ if a.mode != ModeEditor {
+ t.Errorf("after save mode = %d, want ModeEditor", a.mode)
+ }
+}
+
+func TestEditorCtrlNNewBlankWithDirtyConfirm(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "a.md")
+ os.WriteFile(src, []byte("body"), 0o644)
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ a.Load(src)
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // dirty
+
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) // first press: arm, do not discard
+ if a.path != src {
+ t.Errorf("first Ctrl+N discarded without confirm; path = %q", a.path)
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) // second press: new blank
+ if a.path != "" {
+ t.Errorf("after confirm, path = %q, want empty", a.path)
+ }
+ if got := string(a.editor.Bytes()); got != "" {
+ t.Errorf("editor not blank: %q", got)
+ }
+}
internal/config/config.go +17 −0
@@ -18,6 +18,7 @@ DailySubdir string `toml:"daily_subdir"`
DailyFormat string `toml:"daily_format"`
GlamourStyle string `toml:"glamour_style"`
Theme string `toml:"theme"`
+ InboxDir string `toml:"inbox_dir"`
}
// Default returns the built-in configuration used when no file is present.
@@ -70,7 +71,23 @@ }
if fileCfg.Theme != "" {
cfg.Theme = fileCfg.Theme
}
+ if fileCfg.InboxDir != "" {
+ cfg.InboxDir = fileCfg.InboxDir
+ }
return cfg, nil
+}
+
+// InboxRoot is the directory new notes default into: the vault root when
+// InboxDir is empty, an absolute InboxDir as-is, or InboxDir resolved under the
+// vault when relative.
+func (c Config) InboxRoot() string {
+ if c.InboxDir == "" {
+ return c.VaultDir
+ }
+ if filepath.IsAbs(c.InboxDir) {
+ return c.InboxDir
+ }
+ return filepath.Join(c.VaultDir, c.InboxDir)
}
// DailyPath builds the absolute path to the daily note for time t.
internal/config/config_test.go +33 −0
@@ -99,3 +99,36 @@ if got != want {
t.Errorf("DailyPath = %q, want %q", got, want)
}
}
+
+func TestInboxDirDefaultsEmptyAndOverlays(t *testing.T) {
+ if d := Default(); d.InboxDir != "" {
+ t.Errorf("InboxDir default = %q, want empty (vault root)", d.InboxDir)
+ }
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.toml")
+ if err := os.WriteFile(path, []byte("inbox_dir = \"Inbox\"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadFromFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.InboxDir != "Inbox" {
+ t.Errorf("InboxDir = %q, want Inbox", cfg.InboxDir)
+ }
+}
+
+func TestInboxRootResolves(t *testing.T) {
+ c := Config{VaultDir: "/v"}
+ if got := c.InboxRoot(); got != "/v" {
+ t.Errorf("empty InboxDir → %q, want /v (vault root)", got)
+ }
+ c.InboxDir = "Inbox"
+ if got := c.InboxRoot(); got != filepath.Join("/v", "Inbox") {
+ t.Errorf("relative InboxDir → %q, want /v/Inbox", got)
+ }
+ c.InboxDir = "/abs/inbox"
+ if got := c.InboxRoot(); got != "/abs/inbox" {
+ t.Errorf("absolute InboxDir → %q, want /abs/inbox", got)
+ }
+}
main.go +23 −5
@@ -13,25 +13,43 @@ tea "github.com/charmbracelet/bubbletea"
)
func main() {
- daily := flag.Bool("daily", false, "open today's daily note")
- flag.Parse()
-
cfg, err := config.Load()
if err != nil {
fmt.Fprintln(os.Stderr, "glint: config:", err)
}
+ a := app.New(cfg)
+
+ // `glint new [name]` — start a fresh document: a blank unnamed buffer, or a
+ // new note created under the inbox when a name is given.
+ if args := os.Args[1:]; len(args) > 0 && args[0] == "new" {
+ name := ""
+ if len(args) > 1 {
+ name = args[1]
+ }
+ if err := a.StartNew(name); err != nil {
+ fmt.Fprintln(os.Stderr, "glint:", err)
+ os.Exit(1)
+ }
+ run(a)
+ return
+ }
+
+ daily := flag.Bool("daily", false, "open today's daily note")
+ flag.Parse()
var path string
if args := flag.Args(); len(args) > 0 {
path = args[0]
}
-
- a := app.New(cfg)
if err := a.Start(path, *daily); err != nil {
fmt.Fprintln(os.Stderr, "glint:", err)
os.Exit(1)
}
+ run(a)
+}
+// run drives the Bubbletea program in the alternate screen.
+func run(a *app.App) {
if _, err := tea.NewProgram(a, tea.WithAltScreen()).Run(); err != nil {
fmt.Fprintln(os.Stderr, "glint:", err)
os.Exit(1)