From 4bbbc0b88b2681e5790e736c039a516417ac153a Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 18:58:17 -0400 Subject: [PATCH 01/14] Feat(config): a terminal view-mode field and frontmatter rules table zn tui is gaining a way to open a note in edit, split or preview from its own frontmatter. This adds the configuration behind it, without changing what any note does yet. `[terminal] view_mode_field` names the frontmatter field that sets a note's view mode; it is empty by default, which keeps the feature off. `[terminal.view_mode_rules]` maps `"field=value"` to a mode, so a field such as `page-status = done` can choose preview. Both live under `[terminal]`, which the desktop app ignores, so a save from the desktop cannot drop them. Rule keys and values are trimmed, unquoted and lower-cased, and rules are ordered by their lower-cased `field=value`, which is the order the first match will be taken in. A rule with a bad mode, no `=` or an empty field name is kept in the file and reported with its reason but never applied, and an invalid rule never stops the config from loading. SetViewModeRule and RemoveViewModeRule rewrite only that table; removing works on the key exactly as written, so a malformed rule can still be deleted, and removing the last rule drops the table. Tests, written red first, cover the defaults, loading the sub-table, invalid rules and field values, normalising and ordering, and writing and removing rules while keeping the rest of the file. Co-Authored-By: Claude Opus 5.5 --- internal/config/prefs.go | 11 ++ internal/config/prefs_validate.go | 2 +- internal/config/prefs_values.go | 2 +- internal/config/prefs_write.go | 66 +++++++ internal/config/viewmode_prefs_test.go | 239 +++++++++++++++++++++++++ internal/config/viewmode_rules.go | 118 ++++++++++++ internal/config/viewmode_rules_test.go | 78 ++++++++ 7 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 internal/config/viewmode_prefs_test.go create mode 100644 internal/config/viewmode_rules.go create mode 100644 internal/config/viewmode_rules_test.go diff --git a/internal/config/prefs.go b/internal/config/prefs.go index cffb461..6dcb98e 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -80,6 +80,12 @@ type Prefs struct { // TerminalMath is typst or latex: the engine for display math. Anything // else loads as typst; zn doctor reports it. TerminalMath string + // TerminalViewModeField names the frontmatter field that picks a note's + // pane mode when it opens in a new tab. Empty turns the feature off. + TerminalViewModeField string + // ViewModeRules maps "=" to a pane mode, from + // [terminal.view_mode_rules]. Entries are raw; see ViewModeRuleEntries. + ViewModeRules map[string]string // RenderTimeout (seconds) bounds one external render; RenderConcurrency // caps how many run at once. Non-positive values load as the defaults. RenderTimeout int @@ -115,6 +121,7 @@ func DefaultPrefs() Prefs { PreviewStyle: "auto", TerminalMouse: true, TerminalMath: "typst", + ViewModeRules: map[string]string{}, RenderTimeout: 10, RenderConcurrency: 2, UnifiedSidebar: true, @@ -236,6 +243,10 @@ func applyPrefs(p *Prefs, doc map[string]any) { setString(terminal, "images", &p.TerminalImages) setString(terminal, "theme", &p.TerminalTheme) setString(terminal, "math", &p.TerminalMath) + setString(terminal, "view_mode_field", &p.TerminalViewModeField) + if rules, ok := terminal["view_mode_rules"].(map[string]any); ok { + p.ViewModeRules = mergeStringTable(map[string]string{}, rules) + } setInt(terminal, "render_timeout", &p.RenderTimeout) setInt(terminal, "render_concurrency", &p.RenderConcurrency) if strings.TrimSpace(p.PreviewStyle) == "" { diff --git a/internal/config/prefs_validate.go b/internal/config/prefs_validate.go index eaf7e6d..422466a 100644 --- a/internal/config/prefs_validate.go +++ b/internal/config/prefs_validate.go @@ -27,7 +27,7 @@ func ValidateConfig() error { // lenientKeys are checked only when strict (zn doctor): LoadPrefs falls back // to their defaults, so a bad value never stops zn from starting. var lenientKeys = map[string]map[string]bool{ - "terminal": {"math": true, "render_timeout": true, "render_concurrency": true}, + "terminal": {"math": true, "view_mode_field": true, "render_timeout": true, "render_concurrency": true}, } func validateKnownValues(doc map[string]any, strict bool) error { diff --git a/internal/config/prefs_values.go b/internal/config/prefs_values.go index a183052..0e7139a 100644 --- a/internal/config/prefs_values.go +++ b/internal/config/prefs_values.go @@ -13,7 +13,7 @@ func ValueTables(p Prefs) map[string]map[string]any { "editor": {"tab_size": p.EditorTabSize, "scroll_off": p.EditorScrollOff, "word_wrap": p.WordWrap, "default_view_mode": p.DefaultPaneMode, "line_number_mode": p.LineNumberMode, "completed_task_style": p.CompletedTaskStyle, "time_format": p.TimeFormat, "auto_pairs": p.AutoPairs, "auto_pair_quotes_in_prose": p.AutoPairQuotesInProse, "tabs_enabled": p.TabsEnabled, "wrap_tabs": p.WrapTabs, "keep_view_mode_across_notes": p.RetainViewMode, "markdown_snippets": p.MarkdownSnippets, "hide_builtin_templates": p.HideBuiltinTemplates, "text_replacements_enabled": p.TextReplacementsEnabled, "sync_title_heading_on_rename": p.SyncTitleHeadingOnRename}, "appearance": {"theme_family": p.ThemeFamily, "theme_mode": p.ThemeMode, "theme_id": p.ThemeID, "unified_sidebar": p.UnifiedSidebar}, "view": {"note_sort_order": p.NoteSortOrder, "group_by_kind": p.GroupByKind, "nested_tags": p.NestedTags, "quick_note_date_title": p.QuickNoteDateTitle, "quick_note_title_prefix": p.QuickNoteTitlePrefix, "calendar_week_start": p.CalendarWeekStart, "calendar_show_week_numbers": p.CalendarShowWeekNumbers, "tasks_view_mode": p.TasksViewMode, "show_archived_tasks": p.ShowArchivedTasks, "kanban_group_by": p.KanbanGroupBy, "kanban_folder_root": p.KanbanFolderRoot}, - "terminal": {"mouse": p.TerminalMouse, "preview_style": p.PreviewStyle, "images": p.TerminalImages, "theme": p.TerminalTheme, "math": p.TerminalMath, "render_timeout": p.RenderTimeout, "render_concurrency": p.RenderConcurrency}, + "terminal": {"mouse": p.TerminalMouse, "preview_style": p.PreviewStyle, "images": p.TerminalImages, "theme": p.TerminalTheme, "math": p.TerminalMath, "view_mode_field": p.TerminalViewModeField, "render_timeout": p.RenderTimeout, "render_concurrency": p.RenderConcurrency}, } } diff --git a/internal/config/prefs_write.go b/internal/config/prefs_write.go index 8793aec..37178b3 100644 --- a/internal/config/prefs_write.go +++ b/internal/config/prefs_write.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "github.com/BurntSushi/toml" @@ -33,6 +34,71 @@ func SetValue(section, key string, value any) error { }) } +// SetViewModeRule adds or changes one [terminal.view_mode_rules] entry, +// keeping every other key and table. Rules already in the file that are +// malformed never block the write. +func SetViewModeRule(key, mode string) error { + if err := ValidateViewModeRule(key, mode); err != nil { + return err + } + mode = strings.ToLower(strings.TrimSpace(mode)) + return UpdateConfig(func(doc map[string]any) error { + terminal, rules, err := viewModeTables(doc, true) + if err != nil { + return err + } + rules[key] = mode + terminal["view_mode_rules"] = rules + doc["terminal"] = terminal + return nil + }) +} + +// RemoveViewModeRule deletes the entry stored under the raw key exactly as +// written, so malformed keys can be removed too. Removing the last rule +// drops the empty table. +func RemoveViewModeRule(key string) error { + return UpdateConfig(func(doc map[string]any) error { + terminal, rules, err := viewModeTables(doc, false) + if err != nil || rules == nil { + return err + } + delete(rules, key) + if len(rules) == 0 { + delete(terminal, "view_mode_rules") + } + return nil + }) +} + +// viewModeTables returns [terminal] and its rules sub-table, creating them +// when create is set; otherwise missing tables come back nil. +func viewModeTables(doc map[string]any, create bool) (terminal, rules map[string]any, err error) { + raw, exists := doc["terminal"] + terminal, ok := raw.(map[string]any) + if exists && !ok { + return nil, nil, fmt.Errorf("terminal is not a config table") + } + if !exists { + if !create { + return nil, nil, nil + } + terminal = map[string]any{} + } + raw, exists = terminal["view_mode_rules"] + rules, ok = raw.(map[string]any) + if exists && !ok { + return nil, nil, fmt.Errorf("terminal.view_mode_rules is not a config table") + } + if !exists { + if !create { + return terminal, nil, nil + } + rules = map[string]any{} + } + return terminal, rules, nil +} + // UpdateConfig applies a targeted edit to the latest file. A parse failure or // a concurrent external edit leaves the original file untouched. func UpdateConfig(edit func(map[string]any) error) error { diff --git a/internal/config/viewmode_prefs_test.go b/internal/config/viewmode_prefs_test.go new file mode 100644 index 0000000..0ee7f95 --- /dev/null +++ b/internal/config/viewmode_prefs_test.go @@ -0,0 +1,239 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/BurntSushi/toml" +) + +func writeConfigFile(t *testing.T, body string) { + t.Helper() + dir := t.TempDir() + t.Setenv("ZENNOTES_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func readConfigDoc(t *testing.T) map[string]any { + t.Helper() + var doc map[string]any + if _, err := toml.DecodeFile(ConfigTomlPath(), &doc); err != nil { + t.Fatal(err) + } + return doc +} + +func ruleTable(doc map[string]any) map[string]any { + terminal, _ := doc["terminal"].(map[string]any) + rules, _ := terminal["view_mode_rules"].(map[string]any) + return rules +} + +// A fresh config turns both the field and the rules off. +func TestViewModeDefaultsAreOff(t *testing.T) { + if p := DefaultPrefs(); p.TerminalViewModeField != "" || len(p.ViewModeRules) != 0 { + t.Fatalf("defaults: field %q rules %v", p.TerminalViewModeField, p.ViewModeRules) + } + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + p, _, err := LoadPrefs() + if err != nil || p.TerminalViewModeField != "" || len(p.ViewModeRules) != 0 { + t.Fatalf("no file: field %q rules %v err %v", p.TerminalViewModeField, p.ViewModeRules, err) + } + writeTerminalConfig(t, `math = "latex"`) + p, _, err = LoadPrefs() + if err != nil || p.TerminalViewModeField != "" || len(p.ViewModeRules) != 0 { + t.Fatalf("no keys: field %q rules %v err %v", p.TerminalViewModeField, p.ViewModeRules, err) + } + if strings.Contains(DefaultConfigTOML, "view_mode_field = \"view-mode\"") { + t.Fatal("the starter config must leave the field off") + } +} + +// [terminal] holds scalars plus the rules sub-table; both load. +func TestViewModeRulesLoadFromTerminalSubtable(t *testing.T) { + writeConfigFile(t, `[terminal] +math = "latex" +view_mode_field = "view-mode" + +[terminal.view_mode_rules] +"page-status=done" = "preview" +"kind=journal" = "split" +`) + p, _, err := LoadPrefs() + if err != nil { + t.Fatal(err) + } + if p.TerminalViewModeField != "view-mode" || p.TerminalMath != "latex" { + t.Fatalf("scalars: field %q math %q", p.TerminalViewModeField, p.TerminalMath) + } + want := map[string]string{"page-status=done": "preview", "kind=journal": "split"} + if !reflect.DeepEqual(p.ViewModeRules, want) { + t.Fatalf("rules: got %v want %v", p.ViewModeRules, want) + } +} + +// A hand-edited bad rule never stops the config from loading. +func TestInvalidViewModeRulesDoNotBlockLoad(t *testing.T) { + writeConfigFile(t, `[terminal] +math = "latex" + +[terminal.view_mode_rules] +"page-status=done" = "preview" +"kind=journal" = "reading" +"status" = "edit" +"=x" = "edit" +`) + p, _, err := LoadPrefs() + if err != nil { + t.Fatalf("LoadPrefs: %v", err) + } + if p.TerminalMath != "latex" || p.ViewModeRules["page-status=done"] != "preview" { + t.Fatalf("rest of the config lost: math %q rules %v", p.TerminalMath, p.ViewModeRules) + } + if err := ValidateConfig(); err != nil && strings.Contains(err.Error(), "view_mode_rules") { + t.Fatalf("a bad rule must not fail validation: %v", err) + } +} + +// A wrong-typed field leaves the field off and loading intact. +func TestBadViewModeFieldTypeDoesNotBlockLoad(t *testing.T) { + writeTerminalConfig(t, "view_mode_field = 3\nmath = \"latex\"") + p, _, err := LoadPrefs() + if err != nil { + t.Fatalf("LoadPrefs: %v", err) + } + if p.TerminalViewModeField != "" || p.TerminalMath != "latex" { + t.Fatalf("field %q math %q", p.TerminalViewModeField, p.TerminalMath) + } +} + +// Writing a rule keeps the rest of [terminal], the other tables and unknown data. +func TestSetViewModeRuleKeepsOtherTablesAndKeys(t *testing.T) { + writeConfigFile(t, `[terminal] +math = "latex" + +[editor] +word_wrap = false + +[unrecognized.nested] +list = [1, 2, 3] +`) + if err := SetViewModeRule("page-status=done", "preview"); err != nil { + t.Fatal(err) + } + if err := SetViewModeRule("kind=journal", "split"); err != nil { + t.Fatal(err) + } + doc := readConfigDoc(t) + rules := ruleTable(doc) + if rules["page-status=done"] != "preview" || rules["kind=journal"] != "split" || len(rules) != 2 { + t.Fatalf("rules: %v", rules) + } + if doc["terminal"].(map[string]any)["math"] != "latex" { + t.Fatalf("[terminal] math lost: %v", doc["terminal"]) + } + if doc["editor"].(map[string]any)["word_wrap"] != false || doc["unrecognized"] == nil { + t.Fatalf("other tables lost: %v", doc) + } + if err := SetViewModeRule("page-status=done", "edit"); err != nil { + t.Fatal(err) + } + if got := ruleTable(readConfigDoc(t))["page-status=done"]; got != "edit" { + t.Fatalf("changed rule: %v", got) + } + p, _, err := LoadPrefs() + if err != nil || p.ViewModeRules["page-status=done"] != "edit" { + t.Fatalf("reload: %v err %v", p.ViewModeRules, err) + } +} + +// Removing the last rule drops the empty table but keeps [terminal]. +func TestRemoveLastViewModeRuleDropsTable(t *testing.T) { + writeConfigFile(t, `[terminal] +math = "latex" + +[terminal.view_mode_rules] +"page-status=done" = "preview" +"kind=journal" = "split" +`) + if err := RemoveViewModeRule("page-status=done"); err != nil { + t.Fatal(err) + } + if rules := ruleTable(readConfigDoc(t)); len(rules) != 1 || rules["kind=journal"] != "split" { + t.Fatalf("after first removal: %v", rules) + } + if err := RemoveViewModeRule("kind=journal"); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(ConfigTomlPath()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "view_mode_rules") { + t.Fatalf("empty rules table kept:\n%s", raw) + } + if readConfigDoc(t)["terminal"].(map[string]any)["math"] != "latex" { + t.Fatalf("[terminal] lost:\n%s", raw) + } +} + +// A malformed key is removed by its raw key exactly as written. +func TestRemoveMalformedViewModeRule(t *testing.T) { + writeConfigFile(t, `[terminal.view_mode_rules] +"status" = "edit" +"=x" = "edit" +"kind=journal" = "reading" +"page-status=done" = "preview" +`) + for _, key := range []string{"status", "=x", "kind=journal"} { + if err := RemoveViewModeRule(key); err != nil { + t.Fatalf("remove %q: %v", key, err) + } + } + rules := ruleTable(readConfigDoc(t)) + if len(rules) != 1 || rules["page-status=done"] != "preview" { + t.Fatalf("rules after removal: %v", rules) + } +} + +// A bad rule already in the file never blocks a later write. +func TestWritingRuleWithExistingInvalidRuleSucceeds(t *testing.T) { + writeConfigFile(t, `[terminal.view_mode_rules] +"status" = "edit" +"kind=journal" = "reading" +`) + if err := SetViewModeRule("page-status=done", "preview"); err != nil { + t.Fatalf("set with a bad rule present: %v", err) + } + rules := ruleTable(readConfigDoc(t)) + if rules["page-status=done"] != "preview" || rules["status"] != "edit" || rules["kind=journal"] != "reading" { + t.Fatalf("rules: %v", rules) + } + if err := SetValue("terminal", "math", "latex"); err != nil { + t.Fatalf("unrelated write with a bad rule present: %v", err) + } + if len(ruleTable(readConfigDoc(t))) != 3 { + t.Fatal("rules lost by an unrelated write") + } +} + +// The field is settable through the writer :settings and zn config use, and +// listed among the editable terminal settings. +func TestViewModeFieldRoundTrips(t *testing.T) { + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + if err := SetValue("terminal", "view_mode_field", "view-mode"); err != nil { + t.Fatal(err) + } + p, _, err := LoadPrefs() + if err != nil || p.TerminalViewModeField != "view-mode" { + t.Fatalf("field %q err %v", p.TerminalViewModeField, err) + } + if got := ValueTables(p)["terminal"]["view_mode_field"]; got != "view-mode" { + t.Fatalf("ValueTables: %v", got) + } +} diff --git a/internal/config/viewmode_rules.go b/internal/config/viewmode_rules.go new file mode 100644 index 0000000..5511ebc --- /dev/null +++ b/internal/config/viewmode_rules.go @@ -0,0 +1,118 @@ +package config + +import ( + "sort" + "strings" + + "github.com/ZenNotes/tui/internal/vault" +) + +// ViewModeRule is one valid [terminal.view_mode_rules] entry in normalised +// form: the frontmatter key and value are lower-cased and unquoted, and the +// mode is edit, split or preview. +type ViewModeRule struct { + Key string + Value string + Mode string +} + +// ViewModeRuleEntry is one entry as written in the file. Key and Mode are +// the raw strings, so a malformed entry can still be shown and removed. A +// valid entry also carries its normalised Rule. +type ViewModeRuleEntry struct { + Key string + Mode string + Valid bool + Reason string + Rule ViewModeRule +} + +// ViewModeModes are the pane modes a rule can name. +var ViewModeModes = []string{"edit", "split", "preview"} + +func normalizeViewModeMode(mode string) (string, bool) { + m := strings.ToLower(strings.TrimSpace(mode)) + for _, v := range ViewModeModes { + if m == v { + return m, true + } + } + return "", false +} + +func normalizeViewModePart(s string) string { + return strings.ToLower(strings.TrimSpace(vault.Unquote(s))) +} + +// ParseViewModeRule validates one "=" entry and its mode. The +// first "=" splits key from value. On failure it returns a short reason. +func ParseViewModeRule(key, mode string) (ViewModeRule, string) { + k, v, found := strings.Cut(key, "=") + if !found { + return ViewModeRule{}, `no "="` + } + k = normalizeViewModePart(k) + if k == "" { + return ViewModeRule{}, "empty key" + } + m, ok := normalizeViewModeMode(mode) + if !ok { + return ViewModeRule{}, "bad mode" + } + return ViewModeRule{Key: k, Value: normalizeViewModePart(v), Mode: m}, "" +} + +// ValidateViewModeRule is the check writers run before storing a rule: the +// key must hold "=", a non-empty field and a non-empty value, and the mode +// must be edit, split or preview. +func ValidateViewModeRule(key, mode string) error { + rule, reason := ParseViewModeRule(key, mode) + if reason == "" && rule.Value == "" { + reason = "empty value" + } + if reason != "" { + return &viewModeRuleError{reason} + } + return nil +} + +type viewModeRuleError struct{ reason string } + +func (e *viewModeRuleError) Error() string { return "invalid view-mode rule: " + e.reason } + +// ViewModeRuleEntries returns every entry, valid or not. Valid ones come +// first in match order, then the invalid ones sorted by raw key. +func ViewModeRuleEntries(raw map[string]string) []ViewModeRuleEntry { + var valid, invalid []ViewModeRuleEntry + for k, m := range raw { + rule, reason := ParseViewModeRule(k, m) + e := ViewModeRuleEntry{Key: k, Mode: m, Valid: reason == "", Reason: reason, Rule: rule} + if e.Valid { + valid = append(valid, e) + } else { + invalid = append(invalid, e) + } + } + sort.Slice(valid, func(i, j int) bool { + a, b := valid[i], valid[j] + ak, bk := a.Rule.Key+"="+a.Rule.Value, b.Rule.Key+"="+b.Rule.Value + if ak != bk { + return ak < bk + } + return a.Key < b.Key + }) + sort.Slice(invalid, func(i, j int) bool { return invalid[i].Key < invalid[j].Key }) + return append(valid, invalid...) +} + +// ValidViewModeRules returns the usable rules in match order: sorted by the +// lower-cased "key=value" in byte order. Invalid entries are left out. +func ValidViewModeRules(raw map[string]string) []ViewModeRule { + var out []ViewModeRule + for _, e := range ViewModeRuleEntries(raw) { + if e.Valid { + out = append(out, e.Rule) + } + } + return out +} diff --git a/internal/config/viewmode_rules_test.go b/internal/config/viewmode_rules_test.go new file mode 100644 index 0000000..0339681 --- /dev/null +++ b/internal/config/viewmode_rules_test.go @@ -0,0 +1,78 @@ +package config + +import ( + "reflect" + "testing" +) + +// ViewModeRule is a normalised valid rule, ValidViewModeRules gives the valid +// ones in match order, and ViewModeRuleEntries gives every entry with its raw +// key and mode, a Valid flag and a Reason for the invalid ones. + +func TestViewModeRulesParseNormaliseAndSort(t *testing.T) { + raw := map[string]string{ + "page-status=done": "preview", + "Kind=Journal": "split", + ` "Tags" = "Draft" `: "Edit", + "a=b": " PREVIEW ", + "page-status=in-done": "edit", + } + want := []ViewModeRule{ + {Key: "a", Value: "b", Mode: "preview"}, + {Key: "kind", Value: "journal", Mode: "split"}, + {Key: "page-status", Value: "done", Mode: "preview"}, + {Key: "page-status", Value: "in-done", Mode: "edit"}, + {Key: "tags", Value: "draft", Mode: "edit"}, + } + if got := ValidViewModeRules(raw); !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v\nwant %+v", got, want) + } +} + +func TestInvalidViewModeRulesAreIgnoredButReported(t *testing.T) { + raw := map[string]string{ + "page-status=done": "preview", + "kind=journal": "reading", + "status": "edit", + "=x": "edit", + } + valid := ValidViewModeRules(raw) + if len(valid) != 1 || valid[0].Key != "page-status" { + t.Fatalf("valid rules: %+v", valid) + } + entries := ViewModeRuleEntries(raw) + if len(entries) != len(raw) { + t.Fatalf("entries: %+v", entries) + } + reasons := map[string]string{} + for _, e := range entries { + if e.Valid != (e.Key == "page-status=done") { + t.Fatalf("entry %+v: wrong validity", e) + } + if !e.Valid { + if e.Reason == "" { + t.Fatalf("entry %+v: no reason", e) + } + reasons[e.Key] = e.Reason + } + } + if len(reasons) != 3 || reasons["kind=journal"] == reasons["status"] || reasons["status"] == reasons["=x"] { + t.Fatalf("reasons should tell the three failures apart: %v", reasons) + } +} + +// An empty value part is a rule like any other when read from the file. +func TestViewModeRuleWithEmptyValueIsValid(t *testing.T) { + valid := ValidViewModeRules(map[string]string{"status=": "edit"}) + if len(valid) != 1 || valid[0].Key != "status" || valid[0].Value != "" { + t.Fatalf("got %+v", valid) + } +} + +// The first "=" splits key from value. +func TestViewModeRuleSplitsOnFirstEquals(t *testing.T) { + valid := ValidViewModeRules(map[string]string{"a=b=c": "edit"}) + if len(valid) != 1 || valid[0].Key != "a" || valid[0].Value != "b=c" { + t.Fatalf("got %+v", valid) + } +} From cb78af24fff87805310afdff0a3cf82a6f3f4b13 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:01:46 -0400 Subject: [PATCH 02/14] Feat(tui): work out a note's view mode from its frontmatter frontmatterPaneMode reads a note's frontmatter and says which pane mode it asks for, for the coming change that opens notes in that mode. Nothing calls it yet. A valid value in the configured field (`view-mode: preview`) wins. The field name and its value compare without regard to case; a list, an empty value or any word other than edit, split or preview gives no mode, and the rules are tried instead. A rule matches when its field holds its value, or any element of a list field does, compared case-insensitively after unquoting; rules are tried in the order they arrive in, so the first match wins. A note without frontmatter, or a vault with no field and no rules, gets no mode. Tests cover the field, every way it can fall through, rule matching with case, quotes and lists, rule order, and a field that beats a matching rule. Co-Authored-By: Claude Opus 5.5 --- internal/tui/viewmode_frontmatter.go | 54 ++++++++++++++++++++++ internal/tui/viewmode_frontmatter_test.go | 56 +++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 internal/tui/viewmode_frontmatter.go create mode 100644 internal/tui/viewmode_frontmatter_test.go diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go new file mode 100644 index 0000000..e64b2bd --- /dev/null +++ b/internal/tui/viewmode_frontmatter.go @@ -0,0 +1,54 @@ +package tui + +import ( + "sort" + "strings" + + "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/vault" +) + +// frontmatterPaneMode picks the pane mode a note's frontmatter asks for. +// A valid value in the named field wins. Otherwise the first rule, in the +// order given, that matches a frontmatter key and value wins. A list field +// matches a rule when any element does. It reports false when nothing +// applies, including when the note has no frontmatter. +func frontmatterPaneMode(text, field string, rules []config.ViewModeRule) (paneMode, bool) { + field = strings.ToLower(strings.TrimSpace(field)) + if field == "" && len(rules) == 0 { + return "", false + } + block, _, ok := vault.Frontmatter(text) + if !ok { + return "", false + } + if field != "" { + scalars, _ := vault.ParseFrontmatterScalars(text) + keys := make([]string, 0, len(scalars)) + for k := range scalars { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if strings.ToLower(k) != field { + continue + } + switch v := strings.ToLower(strings.TrimSpace(scalars[k])); v { + case string(modeEdit), string(modeSplit), string(modePreview): + return paneMode(v), true + } + } + } + if len(rules) == 0 { + return "", false + } + fields := vault.ParseFrontmatterFields(block) + for _, r := range rules { + for _, el := range fields[r.Key] { + if strings.ToLower(strings.TrimSpace(vault.Unquote(el))) == r.Value { + return paneMode(r.Mode), true + } + } + } + return "", false +} diff --git a/internal/tui/viewmode_frontmatter_test.go b/internal/tui/viewmode_frontmatter_test.go new file mode 100644 index 0000000..e7e054b --- /dev/null +++ b/internal/tui/viewmode_frontmatter_test.go @@ -0,0 +1,56 @@ +package tui + +import ( + "testing" + + "github.com/ZenNotes/tui/internal/config" +) + +func TestFrontmatterPaneMode(t *testing.T) { + rules := func(raw map[string]string) []config.ViewModeRule { return config.ValidViewModeRules(raw) } + fm := func(body string) string { return "---\n" + body + "\n---\n\nbody\n" } + done := rules(map[string]string{"page-status=done": "preview"}) + + tests := []struct { + name string + text string + field string + rules []config.ViewModeRule + want paneMode + ok bool + }{ + {"no frontmatter", "just text\n", "view-mode", done, "", false}, + {"unrelated keys", fm("title: x\ntags: [a]"), "view-mode", done, "", false}, + {"field edit", fm("view-mode: edit"), "view-mode", nil, modeEdit, true}, + {"field split", fm("view-mode: split"), "view-mode", nil, modeSplit, true}, + {"field preview", fm("view-mode: preview"), "view-mode", nil, modePreview, true}, + {"key case", fm("View-Mode: preview"), "view-mode", nil, modePreview, true}, + {"value case", fm("view-mode: Preview"), "view-mode", nil, modePreview, true}, + {"value spaces and quotes", fm(`view-mode: " preview "`), "view-mode", nil, modePreview, true}, + {"unknown word", fm("view-mode: reading"), "view-mode", nil, "", false}, + {"unknown word falls to rule", fm("view-mode: reading\npage-status: done"), "view-mode", done, modePreview, true}, + {"empty value", fm("view-mode:"), "view-mode", nil, "", false}, + {"inline list", fm("view-mode: [preview]"), "view-mode", nil, "", false}, + {"block list", fm("view-mode:\n - preview"), "view-mode", nil, "", false}, + {"list field falls to rule", fm("view-mode: [preview]\npage-status: done"), "view-mode", done, modePreview, true}, + {"empty field with rule", fm("view-mode: edit\npage-status: done"), "", done, modePreview, true}, + {"rule hit", fm("page-status: done"), "", done, modePreview, true}, + {"rule miss", fm("page-status: draft"), "", done, "", false}, + {"two rules one key", fm("page-status: draft"), "", rules(map[string]string{"page-status=done": "preview", "page-status=draft": "split"}), modeSplit, true}, + {"rule case and quotes", fm(`Page-Status: "DONE"`), "", rules(map[string]string{`"Page-Status"="Done"`: "preview"}), modePreview, true}, + {"rule normalised", fm(`Page-Status: "DONE"`), "", rules(map[string]string{` Page-Status = "Done" `: "Preview"}), modePreview, true}, + {"inline list element", fm("status: [draft, published]"), "", rules(map[string]string{"status=published": "preview"}), modePreview, true}, + {"block list element", fm("status:\n - draft\n - published"), "", rules(map[string]string{"status=published": "split"}), modeSplit, true}, + {"first sorted rule wins", fm("kind: journal\npage-status: done"), "", rules(map[string]string{"page-status=done": "preview", "kind=journal": "edit"}), modeEdit, true}, + {"invalid rule ignored", fm("page-status: done\nkind: journal"), "", rules(map[string]string{"kind=journal": "reading", "no-equals": "edit", "page-status=done": "split"}), modeSplit, true}, + {"field beats rule", fm("view-mode: edit\npage-status: done"), "view-mode", done, modeEdit, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := frontmatterPaneMode(tc.text, tc.field, tc.rules) + if got != tc.want || ok != tc.ok { + t.Fatalf("got (%q, %v), want (%q, %v)", got, ok, tc.want, tc.ok) + } + }) + } +} From c1de97e5443448e5af024009c490f26b82e2b063 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:15:13 -0400 Subject: [PATCH 03/14] Feat(tui): a note opens in the view mode its frontmatter asks for With `[terminal] view_mode_field` set or a valid rule in `[terminal.view_mode_rules]`, a note opened in a new tab now takes the pane mode its frontmatter asks for, both from openNote and from quiet opens such as the jump list and pickers. A note's body is often read in the background after its tab appears, so the tab opens in the mode it would have had anyway and is marked as waiting; once the text is loaded the frontmatter sets the mode, and the tab visibly switches on a slow read rather than holding up the open. A manual mode switch before the load clears the mark, so the user's choice is never overridden. The frontmatter mode beats the remembered per-note mode and the keep-across mode, but only writes the tab: neither of those is changed by it, so one finished note does not make every later note open in preview. A tab that is already open keeps its mode when the note is saved with new frontmatter; the new value applies the next time the note is opened. With the field empty and no valid rules, which is the default, no tab is marked and opening a note behaves exactly as before. Tests cover the default config, the field and a rule on open, both beating the remembered and keep-across modes, a deferred read, a manual switch before and after the load, saving and reopening, quiet opens, and that applying a mode leaves the remembered modes alone. Co-Authored-By: Claude Opus 5.5 --- internal/tui/input.go | 1 + internal/tui/noteops.go | 4 +- internal/tui/panes.go | 5 + internal/tui/viewmode_frontmatter.go | 48 +++++ internal/tui/viewmode_open_test.go | 252 +++++++++++++++++++++++++++ 5 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 internal/tui/viewmode_open_test.go diff --git a/internal/tui/input.go b/internal/tui/input.go index b07d287..e92ae41 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -597,6 +597,7 @@ func (a *App) setPaneMode(mode paneMode) { return } t.mode = mode + t.frontmatterPending = false if a.prefs.RetainViewMode { a.prefs.DefaultPaneMode = string(mode) } diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index b013b50..4ba375e 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -918,8 +918,10 @@ func (a *App) openNoteQuiet(path string) { if mode == "" { mode = modeEdit } - pane.tabs = append(pane.tabs, &tab{path: path, mode: mode}) + t := &tab{path: path, mode: mode} + pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 + a.watchFrontmatterMode(t) a.touchRecent(path) a.markSessionDirty() } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index fbf071a..b321d0a 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -43,6 +43,10 @@ type tab struct { preview *previewState view view pinned bool + // frontmatterPending is set while a freshly opened tab still waits for + // its note to load, so the frontmatter can pick the pane mode. A manual + // mode switch clears it. + frontmatterPending bool } // pane is a leaf of the split tree holding tabs. @@ -239,6 +243,7 @@ func (a *App) openNote(path string, focus bool) { t := &tab{path: path, mode: mode} pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 + a.watchFrontmatterMode(t) } if cur := a.activeBuffer(); cur != nil && cur.path != path { a.pushJump(cur.path, cur.ed.Cursor()) diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go index e64b2bd..5502b41 100644 --- a/internal/tui/viewmode_frontmatter.go +++ b/internal/tui/viewmode_frontmatter.go @@ -52,3 +52,51 @@ func frontmatterPaneMode(text, field string, rules []config.ViewModeRule) (paneM } return "", false } + +// frontmatterModeOn reports whether the configuration asks notes to pick +// their pane mode from frontmatter. +func (a *App) frontmatterModeOn() bool { + return strings.TrimSpace(a.prefs.TerminalViewModeField) != "" || + len(config.ValidViewModeRules(a.prefs.ViewModeRules)) > 0 +} + +// watchFrontmatterMode marks a newly opened note tab so that its frontmatter +// sets the pane mode once the note text is available. It does nothing when +// the feature is off, so the default open path is unchanged. +func (a *App) watchFrontmatterMode(t *tab) { + if !a.frontmatterModeOn() || isVirtualPath(t.path) { + return + } + t.frontmatterPending = true + a.withLoadedBuffer(t.path, a.applyFrontmatterMode) +} + +// applyFrontmatterMode sets the pane mode of every still-pending tab on the +// loaded note. It writes the tab only: the remembered per-note modes and the +// keep-across default stay as the user left them. +func (a *App) applyFrontmatterMode(buf *noteBuffer) { + var pending []*tab + for _, p := range a.panes.leaves() { + for _, t := range p.tabs { + if t.path == buf.path && t.frontmatterPending { + t.frontmatterPending = false + pending = append(pending, t) + } + } + } + if len(pending) == 0 { + return + } + mode, ok := frontmatterPaneMode(buf.ed.Text(), a.prefs.TerminalViewModeField, config.ValidViewModeRules(a.prefs.ViewModeRules)) + if !ok { + return + } + for _, t := range pending { + t.mode = mode + if mode != modeEdit && t.preview == nil { + t.preview = &previewState{} + } + } + a.layout() + a.markSessionDirty() +} diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go new file mode 100644 index 0000000..aa4ac37 --- /dev/null +++ b/internal/tui/viewmode_open_test.go @@ -0,0 +1,252 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" +) + +const fmPreviewNote = "---\nview-mode: preview\npage-status: done\n---\n# Body\n" + +func writeVaultNote(t *testing.T, root, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +// loadDeferred finishes a deferred read of an open note. +func loadDeferred(t *testing.T, a *App, path string) { + t.Helper() + buf := a.buffers[path] + if buf == nil { + t.Fatalf("no buffer for %s", path) + } + a.applyBufferRead(a.readBufferCmd(buf)().(bufferReadMsg)) +} + +func modeOf(t *testing.T, a *App, path string) paneMode { + t.Helper() + for _, p := range a.panes.leaves() { + if i := p.findTab(path); i >= 0 { + return p.tabs[i].mode + } + } + t.Fatalf("no tab for %s", path) + return "" +} + +func enableViewModeField(a *App) { a.prefs.TerminalViewModeField = "view-mode" } + +func TestDefaultConfigIgnoresViewModeFrontmatter(t *testing.T) { + for _, tc := range []struct { + name string + setup func(a *App) + want paneMode + }{ + {"default edit", func(a *App) { a.prefs.DefaultPaneMode = "edit" }, modeEdit}, + {"default split", func(a *App) { a.prefs.DefaultPaneMode = "split" }, modeSplit}, + {"remembered", func(a *App) { a.noteModes["n.md"] = modeSplit }, modeSplit}, + {"keep across", func(a *App) { a.prefs.RetainViewMode = true; a.prefs.DefaultPaneMode = "split" }, modeSplit}, + } { + t.Run(tc.name, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + tc.setup(a) + a.deferReads = true + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != tc.want { + t.Fatalf("before load: %q, want %q", got, tc.want) + } + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != tc.want { + t.Fatalf("after load: %q, want %q", got, tc.want) + } + }) + } +} + +func TestFieldModeAppliesOnOpen(t *testing.T) { + for _, m := range []paneMode{modeEdit, modeSplit, modePreview} { + t.Run(string(m), func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: "+string(m)+"\n---\nbody\n") + a.prefs.DefaultPaneMode = "split" + if m == modeSplit { + a.prefs.DefaultPaneMode = "edit" + } + enableViewModeField(a) + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != m { + t.Fatalf("mode %q, want %q", got, m) + } + if m != modeEdit && a.activeTab().preview == nil { + t.Fatal("preview state not allocated") + } + }) + } +} + +func TestFrontmatterModeBeatsRememberedMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.noteModes["n.md"] = modeEdit + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q", got) + } +} + +func TestFrontmatterModeBeatsKeepAcrossMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.prefs.RetainViewMode = true + a.prefs.DefaultPaneMode = "edit" + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q", got) + } +} + +func TestRuleModeAppliesOnOpen(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + a.prefs.ViewModeRules = map[string]string{"page-status=done": "split"} + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("mode %q", got) + } +} + +func TestDeferredReadSwitchesModeAfterLoad(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("before load: %q", got) + } + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("after load: %q", got) + } + if a.activeTab().preview == nil { + t.Fatal("preview state not allocated") + } + + t.Run("closed before load", func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + msg := a.readBufferCmd(a.buffers["n.md"])().(bufferReadMsg) + // A loading note cannot be closed through closeTab, so drop the tab + // and its buffer the way a closed pane would. + a.activePane.tabs = nil + a.closeBufferIfUnused("n.md") + a.applyBufferRead(msg) + for _, p := range a.panes.leaves() { + if len(p.tabs) != 0 { + t.Fatalf("stray tab: %d", len(p.tabs)) + } + } + }) +} + +func TestManualSwitchBeforeLoadWins(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + a.setPaneMode(modeSplit) + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("mode %q", got) + } +} + +func TestSavingNewFrontmatterKeepsOpenTabMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "# plain\n") + writeVaultNote(t, root, "other.md", "other\n") + a.prefs.ViewModeRules = map[string]string{"page-status=done": "preview"} + a.openNote("n.md", true) + buf := a.buffers["n.md"] + buf.ed.SetText("---\npage-status: done\n---\n# plain\n") + if err := a.saveBuffer(buf); err != nil { + t.Fatal(err) + } + a.openNote("other.md", true) + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("mode %q", got) + } +} + +func TestReopenAfterCloseUsesNewFrontmatter(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "# plain\n") + a.prefs.ViewModeRules = map[string]string{"page-status=done": "preview"} + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("first open %q", got) + } + a.closeTab(a.activePane, 0) + writeVaultNote(t, root, "n.md", "---\npage-status: done\n---\n# plain\n") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("reopen %q", got) + } +} + +func TestQuietOpenAppliesFrontmatterMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNoteQuiet("n.md") + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("before load %q", got) + } + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("after load %q", got) + } +} + +func TestManualSwitchAfterApplyIsRemembered(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.prefs.RetainViewMode = true + a.openNote("n.md", true) + a.setPaneMode(modeSplit) + if a.noteModes["n.md"] != modeSplit || a.prefs.DefaultPaneMode != "split" { + t.Fatalf("noteModes %q, default %q", a.noteModes["n.md"], a.prefs.DefaultPaneMode) + } +} + +func TestAppliedModeLeavesKeepAcrossAndRememberedAlone(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.prefs.RetainViewMode = true + a.prefs.DefaultPaneMode = "edit" + a.deferReads = true + a.openNote("n.md", true) + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q", got) + } + if a.prefs.DefaultPaneMode != "edit" { + t.Fatalf("keep-across default changed to %q", a.prefs.DefaultPaneMode) + } + if _, ok := a.noteModes["n.md"]; ok { + t.Fatal("applied mode was remembered") + } +} From 1cb85fba5444b799b2232e985ba45cb036f02594 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 21:24:53 -0400 Subject: [PATCH 04/14] Feat(tui): split copies, restored tabs and references respect view modes A tab opened in the mode its frontmatter asks for has three neighbours that must not lose or override that choice. Splitting a tab before its note has loaded now gives the copy the same waiting state, so both tabs take the frontmatter mode once the text is in. A copy of a tab whose mode was switched by hand keeps that mode, as before. A tab restored from the last session keeps the mode it was saved with; the frontmatter does not change it. A note pinned as a reference with `:ref` always stays in preview, whatever its frontmatter asks for. Tests cover a restored tab, splits before and after the load, a split of a manually switched tab, and a reference whose frontmatter asks for edit or split. Co-Authored-By: Claude Opus 5.5 --- internal/tui/commands_parity.go | 1 + internal/tui/panes.go | 2 +- internal/tui/session.go | 2 + internal/tui/viewmode_open_test.go | 104 +++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index f041189..bd7b751 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -722,6 +722,7 @@ func (a *App) openReference(path string) { return } t.mode = modePreview + t.frontmatterPending = false if !t.pinned { a.togglePin(p, p.active) } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index b321d0a..41405fb 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -113,7 +113,7 @@ func (n *paneNode) split(target *pane, vertical bool) *pane { if n.leaf == target { newPane := &pane{} if t := target.activeTab(); t != nil { - copyTab := &tab{path: t.path, mode: t.mode, view: t.view} + copyTab := &tab{path: t.path, mode: t.mode, view: t.view, frontmatterPending: t.frontmatterPending} if t.preview != nil { pv := *t.preview copyTab.preview = &pv diff --git a/internal/tui/session.go b/internal/tui/session.go index e069930..6ebabb6 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -261,6 +261,8 @@ func (a *App) restoreSessionPane(s *sessionPane, depth int) *paneNode { tab.preview = &previewState{} } } + // The saved mode stands; the note's frontmatter must not override it. + tab.frontmatterPending = false } if idx := n.leaf.findTab(selected); idx >= 0 { n.leaf.active = idx diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index aa4ac37..30a2ba5 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -250,3 +250,107 @@ func TestAppliedModeLeavesKeepAcrossAndRememberedAlone(t *testing.T) { t.Fatal("applied mode was remembered") } } + +func tabsOf(a *App, path string) []*tab { + var out []*tab + for _, p := range a.panes.leaves() { + for _, t := range p.tabs { + if t.path == path { + out = append(out, t) + } + } + } + return out +} + +func modesOf(ts []*tab) []paneMode { + var out []paneMode + for _, t := range ts { + out = append(out, t.mode) + } + return out +} + +func TestRestoredSessionTabKeepsSavedMode(t *testing.T) { + root := newSessionParityVault(t, "n.md") + writeVaultNote(t, root, "n.md", fmPreviewNote) + a := newSessionParityApp(t, root) + a.openNote("n.md", true) + a.activeTab().mode = modeEdit + a.saveSession() + + restored := newSessionParityApp(t, root) + enableViewModeField(restored) + restored.deferReads = true + restored.restoreSession() + if got := modeOf(t, restored, "n.md"); got != modeEdit { + t.Fatalf("before load: %q, want edit", got) + } + loadDeferred(t, restored, "n.md") + if got := modeOf(t, restored, "n.md"); got != modeEdit { + t.Fatalf("after load: %q, want edit", got) + } +} + +func TestSplitCopyFollowsFrontmatterMode(t *testing.T) { + for _, preLoad := range []bool{false, true} { + name := "after load" + if preLoad { + name = "before load" + } + t.Run(name, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + if preLoad { + a.splitPane(true) + loadDeferred(t, a, "n.md") + } else { + loadDeferred(t, a, "n.md") + a.splitPane(true) + } + ts := tabsOf(a, "n.md") + if len(ts) != 2 || ts[0].mode != modePreview || ts[1].mode != modePreview { + t.Fatalf("tabs %d, modes %v", len(ts), modesOf(ts)) + } + }) + } +} + +func TestSplitCopyKeepsManualMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + enableViewModeField(a) + a.deferReads = true + a.openNote("n.md", true) + a.setPaneMode(modeEdit) + a.splitPane(true) + loadDeferred(t, a, "n.md") + ts := tabsOf(a, "n.md") + if len(ts) != 2 || ts[0].mode != modeEdit || ts[1].mode != modeEdit { + t.Fatalf("tabs %d, modes %v", len(ts), modesOf(ts)) + } +} + +func TestReferencePaneStaysPreview(t *testing.T) { + for _, fm := range []string{"edit", "split"} { + t.Run(fm, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: "+fm+"\n---\nbody\n") + writeVaultNote(t, root, "other.md", "# other\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + enableViewModeField(a) + a.deferReads = true + a.openNote("other.md", true) + loadDeferred(t, a, "other.md") + a.openReference("n.md") + loadDeferred(t, a, "n.md") + ts := tabsOf(a, "n.md") + if len(ts) != 1 || ts[0].mode != modePreview { + t.Fatalf("modes %v, want preview", modesOf(ts)) + } + }) + } +} From 5271c09400e1721647a9ee7fa7ac4695dd7527db Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:01:11 -0400 Subject: [PATCH 05/14] Feat(tui): a newly created note opens in the editor, whatever its frontmatter With a view-mode field or rules configured, a note created from a template that says `view-mode: preview`, or whose first fields match a preview rule, would otherwise open straight into preview. A note that has just been made is about to be written, so it should never start there. Every action that creates a note and opens it now marks the note first: a new note, a note from a template, a periodic note created on first open, a new task file, and today's daily note when a rollover creates it. The open that follows uses the mark up, starts in the editor and does not wait for the frontmatter. Notes created without being opened, such as a daily note made by adding a task to it or a saved capture, carry no mark; the first time they are opened they follow their frontmatter like any other note. With the feature off, a new note opens in the mode it does today. Tests cover each of the five creation paths, an existing periodic note, notes created without opening, the feature off, and reopening a new note after closing it. Co-Authored-By: Claude Opus 5.5 --- internal/tui/app.go | 2 + internal/tui/commands_parity.go | 6 + internal/tui/noteops.go | 13 +- internal/tui/panes.go | 8 +- internal/tui/viewmode_frontmatter.go | 16 ++ internal/tui/viewmode_open_test.go | 215 +++++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 2 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index bea4663..c7303f5 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -87,6 +87,7 @@ type App struct { buffers map[string]*noteBuffer registers vim.RegisterStore noteModes map[string]paneMode + newNotes map[string]bool panes *paneNode activePane *pane focus focusTarget @@ -216,6 +217,7 @@ func newApp(ctx context.Context, opts Options, prefs config.Prefs, systemDark bo mouseEnabled: prefs.TerminalMouse, buffers: map[string]*noteBuffer{}, noteModes: map[string]paneMode{}, + newNotes: map[string]bool{}, panes: newPaneTree(), sidebarOpen: true, sidebarWidth: 32, diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index bd7b751..7898ae7 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -529,6 +529,7 @@ func (a *App) rolloverNow() error { } today := time.Now() _, _, title, rel := a.periodicLocation(periodic.Daily, today) + created := false if _, ok := a.noteMeta(rel); !ok { body := a.periodicBody(periodic.Daily, title, today) meta, err := a.backend.CreateNote(a.ctx, vault.FolderInbox, title, "", &body) @@ -538,11 +539,15 @@ func (a *App) rolloverNow() error { a.ignoreChange(meta.Path) a.addNoteToIndex(meta) rel = meta.Path + created = true } n := a.rolloverInto(rel, today) if n == 0 { a.notify("Nothing to roll over") } + if created { + a.markNewNote(rel) + } a.openNote(rel, true) return nil } @@ -906,6 +911,7 @@ func (a *App) newTaskFile(target string) error { } a.ignoreChange(meta.Path) a.addNoteToIndex(meta) + a.markNewNote(meta.Path) a.openNote(meta.Path, true) a.afterTaskChange() }) diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index 4ba375e..0ab3847 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -25,6 +25,9 @@ func (a *App) createNote(folder vault.NoteFolder, title, subpath string, body *s a.notifyError(err.Error()) return } + if open { + a.markNewNote(meta.Path) + } a.ignoreChange(meta.Path) a.addNoteToIndex(meta) if open { @@ -602,6 +605,7 @@ func (a *App) openPeriodicOn(kind periodic.Kind, date time.Time) { if kind == periodic.Daily { a.rolloverUnfinishedTasks(meta.Path, date) } + a.markNewNote(meta.Path) a.openNote(meta.Path, true) a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.GotoLine(buf.ed.LineCount()) }) a.refreshIndex() @@ -675,6 +679,7 @@ func (a *App) createFromTemplate(t templates.Template) { } a.ignoreChange(meta.Path) a.addNoteToIndex(meta) + a.markNewNote(meta.Path) a.openNote(meta.Path, true) if rendered.CursorOffset >= 0 { a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.SetCursor(posForOffset(buf.ed.Text(), rendered.CursorOffset)) }) @@ -906,6 +911,7 @@ func (a *App) openTextSearch(initial string) { func (a *App) openNoteQuiet(path string) { a.forgetEmbedTimeouts() pane := a.activePane + justCreated := a.consumeNewNote(path) if idx := pane.findTab(path); idx >= 0 { pane.active = idx return @@ -918,10 +924,15 @@ func (a *App) openNoteQuiet(path string) { if mode == "" { mode = modeEdit } + if justCreated && a.frontmatterModeOn() { + mode = modeEdit + } t := &tab{path: path, mode: mode} pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 - a.watchFrontmatterMode(t) + if !justCreated { + a.watchFrontmatterMode(t) + } a.touchRecent(path) a.markSessionDirty() } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index 41405fb..f5b45fa 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -226,6 +226,7 @@ func (a *App) openNote(path string, focus bool) { if pane == nil { return } + justCreated := a.consumeNewNote(path) if idx := pane.findTab(path); idx >= 0 { pane.active = idx } else { @@ -240,10 +241,15 @@ func (a *App) openNote(path string, focus bool) { if remembered, ok := a.noteModes[path]; ok && !a.prefs.KeepViewModeAcrossNotes() { mode = remembered } + if justCreated && a.frontmatterModeOn() { + mode = modeEdit + } t := &tab{path: path, mode: mode} pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 - a.watchFrontmatterMode(t) + if !justCreated { + a.watchFrontmatterMode(t) + } } if cur := a.activeBuffer(); cur != nil && cur.path != path { a.pushJump(cur.path, cur.ed.Cursor()) diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go index 5502b41..2867333 100644 --- a/internal/tui/viewmode_frontmatter.go +++ b/internal/tui/viewmode_frontmatter.go @@ -100,3 +100,19 @@ func (a *App) applyFrontmatterMode(buf *noteBuffer) { a.layout() a.markSessionDirty() } + +// markNewNote records that path was just created and is about to be opened, +// so the open starts in the editor instead of taking a mode from frontmatter. +func (a *App) markNewNote(path string) { + a.newNotes[path] = true +} + +// consumeNewNote reports whether path carried a new-note mark and clears it, +// so a later open of the same note is an ordinary one. +func (a *App) consumeNewNote(path string) bool { + if !a.newNotes[path] { + return false + } + delete(a.newNotes, path) + return true +} diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index 30a2ba5..5275d69 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -1,9 +1,16 @@ package tui import ( + "context" "os" "path/filepath" + "strings" "testing" + "time" + + "github.com/ZenNotes/tui/internal/periodic" + "github.com/ZenNotes/tui/internal/templates" + "github.com/ZenNotes/tui/internal/vault" ) const fmPreviewNote = "---\nview-mode: preview\npage-status: done\n---\n# Body\n" @@ -354,3 +361,211 @@ func TestReferencePaneStaysPreview(t *testing.T) { }) } } + +const ( + fmPreviewBody = "---\nview-mode: preview\n---\n# New\n" + fmRuleBody = "---\npage-status: done\n---\n# New\n" +) + +// previewEverywhere makes every frontmatter and default route point at preview +// so only the new-note handling can keep a note in edit. +func previewEverywhere(a *App, defaultMode string) { + enableViewModeField(a) + a.prefs.ViewModeRules = map[string]string{"page-status=done": "preview"} + a.prefs.DefaultPaneMode = defaultMode + a.deferReads = true +} + +func activeNotePath(t *testing.T, a *App) string { + t.Helper() + tb := a.activePane.activeTab() + if tb == nil { + t.Fatal("no active tab") + } + return tb.path +} + +// wantEditBeforeAndAfterLoad checks the active note's mode across the load. +func wantEditBeforeAndAfterLoad(t *testing.T, a *App) string { + t.Helper() + p := activeNotePath(t, a) + if got := modeOf(t, a, p); got != modeEdit { + t.Fatalf("before load: %q, want edit", got) + } + loadDeferred(t, a, p) + if got := modeOf(t, a, p); got != modeEdit { + t.Fatalf("after load: %q, want edit", got) + } + if len(a.newNotes) != 0 { + t.Fatalf("new-note mark left behind: %v", a.newNotes) + } + return p +} + +func TestNewNoteFromTemplateOpensInEdit(t *testing.T) { + for _, body := range []string{fmPreviewBody, fmRuleBody} { + for _, def := range []string{"preview", "split"} { + a, _ := newFeatureTestApp(t) + previewEverywhere(a, def) + tpl := templates.Template{ID: "custom:x", Name: "X", Body: body} + a.createFromTemplate(tpl) + typeText(a, "From Template") + a.overlay.handleKey(a, enterKey()) + wantEditBeforeAndAfterLoad(t, a) + } + } +} + +func TestNewBlankNoteOpensInEditWithFeatureOn(t *testing.T) { + for _, def := range []string{"preview", "split"} { + for _, body := range []string{fmPreviewBody, fmRuleBody} { + a, _ := newFeatureTestApp(t) + previewEverywhere(a, def) + b := body + a.createNote(vault.FolderInbox, "Blank", "", &b, true) + wantEditBeforeAndAfterLoad(t, a) + } + } +} + +func useDailyTemplate(t *testing.T, a *App, body string) { + t.Helper() + raw := templates.ComposeFile(templates.ComposeInput{Name: "Daily FM", Body: body}) + if _, err := a.backend.WriteTemplate(context.Background(), vault.WriteTemplateInput{Slug: "dailyfm", Raw: raw}); err != nil { + t.Fatal(err) + } + a.idx.settings.DailyNotes.Enabled = true + a.idx.settings.DailyNotes.TemplateID = "custom:dailyfm" +} + +func TestNewPeriodicNoteOpensInEdit(t *testing.T) { + for _, body := range []string{fmPreviewBody, fmRuleBody} { + a, _ := newFeatureTestApp(t) + useDailyTemplate(t, a, body) + previewEverywhere(a, "preview") + a.openPeriodicOn(periodic.Daily, time.Now()) + wantEditBeforeAndAfterLoad(t, a) + } +} + +func TestExistingPeriodicNoteFollowsFrontmatter(t *testing.T) { + a, _ := newFeatureTestApp(t) + useDailyTemplate(t, a, fmPreviewBody) + previewEverywhere(a, "edit") + a.openPeriodicOn(periodic.Daily, time.Now()) + p := activeNotePath(t, a) + loadDeferred(t, a, p) + a.closeTab(a.activePane, a.activePane.findTab(p)) + a.openPeriodicOn(periodic.Daily, time.Now()) + if got := modeOf(t, a, p); got != modeEdit { + t.Fatalf("before load: %q", got) + } + loadDeferred(t, a, p) + if got := modeOf(t, a, p); got != modePreview { + t.Fatalf("existing daily note: %q, want preview", got) + } +} + +func TestNewTaskFileOpensInEdit(t *testing.T) { + a, _ := newFeatureTestApp(t) + previewEverywhere(a, "preview") + a.prefs.ViewModeRules = map[string]string{"status=open": "preview"} + if err := a.newTaskFile(""); err != nil { + t.Fatal(err) + } + typeText(a, "Write it") + a.overlay.handleKey(a, enterKey()) + wantEditBeforeAndAfterLoad(t, a) +} + +func TestRolloverCreatedDailyNoteOpensInEdit(t *testing.T) { + a, _ := newFeatureTestApp(t) + useDailyTemplate(t, a, fmPreviewBody) + previewEverywhere(a, "preview") + if err := a.rolloverNow(); err != nil { + t.Fatal(err) + } + wantEditBeforeAndAfterLoad(t, a) +} + +func TestNotesCreatedWithoutOpeningLeaveNoMark(t *testing.T) { + a, _ := newFeatureTestApp(t) + useDailyTemplate(t, a, fmRuleBody) + previewEverywhere(a, "edit") + before := map[string]bool{} + for _, n := range a.idx.notes { + before[n.Path] = true + } + + a.addTaskToDaily("one", time.Now()) + a.saveCapture("Captured\nbody") + if len(a.newNotes) != 0 { + t.Fatalf("mark set: %v", a.newNotes) + } + var created []string + for _, n := range a.idx.notes { + if !before[n.Path] { + created = append(created, n.Path) + } + } + if len(created) == 0 { + t.Fatal("no notes were created") + } + // A note created without opening follows its frontmatter on a later open. + daily := "" + for _, p := range created { + if strings.Contains(p, time.Now().Format("2006-01-02")) { + daily = p + } + } + if daily == "" { + t.Fatalf("daily note not found in %v", created) + } + a.openNote(daily, true) + loadDeferred(t, a, daily) + if got := modeOf(t, a, daily); got != modePreview { + t.Fatalf("later open: %q, want preview", got) + } + + // Without daily notes the task goes to an Inbox "Tasks" note. + a.idx.settings.DailyNotes.Enabled = false + a.addTaskToDaily("two", time.Now()) + if len(a.newNotes) != 0 { + t.Fatalf("mark set: %v", a.newNotes) + } +} + +// With the feature off, a new note's mode is whatever note creation already +// gives it; its frontmatter plays no part, and nothing is left waiting. +func TestNewNoteWithFeatureOffIgnoresFrontmatter(t *testing.T) { + a, _ := newFeatureTestApp(t) + a.prefs.DefaultPaneMode = "split" + b := fmPreviewBody + a.createNote(vault.FolderInbox, "Plain", "", &b, true) + p := activeNotePath(t, a) + if got := modeOf(t, a, p); got == modePreview { + t.Fatalf("mode %q: frontmatter applied with the feature off", got) + } + for _, tb := range tabsOf(a, p) { + if tb.frontmatterPending { + t.Fatal("tab left waiting for frontmatter with the feature off") + } + } + if len(a.newNotes) != 0 { + t.Fatalf("mark left: %v", a.newNotes) + } +} + +func TestReopenedNewNoteFollowsFrontmatter(t *testing.T) { + a, _ := newFeatureTestApp(t) + previewEverywhere(a, "edit") + b := fmPreviewBody + a.createNote(vault.FolderInbox, "Again", "", &b, true) + p := wantEditBeforeAndAfterLoad(t, a) + a.closeTab(a.activePane, a.activePane.findTab(p)) + a.openNote(p, true) + loadDeferred(t, a, p) + if got := modeOf(t, a, p); got != modePreview { + t.Fatalf("reopened: %q, want preview", got) + } +} From 71bd1867902e59981b4c292df353cece03219cde Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:03:53 -0400 Subject: [PATCH 06/14] Feat(cli): zn config lists, reads and sets terminal view-mode rules The view-mode rules live in `[terminal.view_mode_rules]`, a table inside `[terminal]` that `zn config` could not reach: it splits a key at the first dot and only knew the four top-level map tables. `zn config list` now shows every rule as `terminal.view_mode_rules.= = `, invalid ones too, so a hand-edited mistake is visible; `list --json` carries the same entries. `zn config get` reads a rule by that name, and `zn config set` adds a new rule or changes an existing one. Set checks the rule before writing: a bad mode, a key without `=`, an empty field or an empty value exits with an error and leaves config.toml as it was. The mode is stored lower-cased. `terminal.view_mode_field` reads and sets like any other terminal key. Removing a rule stays with the settings view and `zn config edit`, since `zn config` has no unset. Tests cover listing, reading, adding and changing a rule, every kind of bad input, and setting the field. Co-Authored-By: Claude Opus 5.5 --- internal/cli/commands_config.go | 22 +++++- internal/cli/config_viewmode_test.go | 110 +++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 internal/cli/config_viewmode_test.go diff --git a/internal/cli/commands_config.go b/internal/cli/commands_config.go index 190eb37..14533b1 100644 --- a/internal/cli/commands_config.go +++ b/internal/cli/commands_config.go @@ -61,6 +61,10 @@ func cmdConfig(ctx context.Context, args Args) error { return nil } +// viewModeRulePrefix names a frontmatter view-mode rule under terminal; the +// rest of the name is the rule's "=" as written in config.toml. +const viewModeRulePrefix = "view_mode_rules." + func preferenceValues(p config.Prefs) map[string]map[string]any { values := config.ValueTables(p) for section, entries := range map[string]map[string]string{"keymaps": p.KeymapOverrides, "saved_filters": p.SavedTaskFilters, "kanban_column_titles": p.KanbanColumnTitles, "tweaks": p.ThemeTweaks} { @@ -69,6 +73,9 @@ func preferenceValues(p config.Prefs) map[string]map[string]any { values[section][key] = value } } + for _, e := range config.ViewModeRuleEntries(p.ViewModeRules) { + values["terminal"][viewModeRulePrefix+e.Key] = e.Mode + } return values } @@ -109,7 +116,8 @@ func cmdConfigAction(ctx context.Context, args Args, action string) error { } value, exists := values[section][name] if !exists { - if action != "set" || (section != "keymaps" && section != "saved_filters" && section != "kanban_column_titles" && section != "tweaks") { + isRule := section == "terminal" && strings.HasPrefix(name, viewModeRulePrefix) + if action != "set" || (!isRule && section != "keymaps" && section != "saved_filters" && section != "kanban_column_titles" && section != "tweaks") { return fmt.Errorf("unknown preference %q; `zn config list` shows supported keys", key) } value = "" @@ -119,6 +127,18 @@ func cmdConfigAction(ctx context.Context, args Args, action string) error { return errors.New("usage: zn config set ") } raw := args.Positional(1) + if name, isRule := strings.CutPrefix(name, viewModeRulePrefix); isRule && section == "terminal" { + if err := config.SetViewModeRule(name, raw); err != nil { + return fmt.Errorf("invalid value for %s: %w", key, err) + } + mode := strings.ToLower(strings.TrimSpace(raw)) + if args.Bool("json") { + emitJSON(map[string]any{"ok": true, "key": key, "value": mode}) + } else { + emitOK(fmt.Sprintf("%s = %v", key, mode)) + } + return nil + } switch value.(type) { case bool: value, err = strconv.ParseBool(raw) diff --git a/internal/cli/config_viewmode_test.go b/internal/cli/config_viewmode_test.go new file mode 100644 index 0000000..86246ca --- /dev/null +++ b/internal/cli/config_viewmode_test.go @@ -0,0 +1,110 @@ +package cli + +import ( + "os" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/config" +) + +func runConfig(t *testing.T, args ...string) (string, int) { + t.Helper() + var code int + out := captureOutput(t, func() { code = Main(append([]string{"config"}, args...)) }) + return out, code +} + +func TestConfigListShowsViewModeRules(t *testing.T) { + isolatedCLI(t) + if _, code := runConfig(t, "set", "terminal.view_mode_rules.page-status=done", "preview"); code != 0 { + t.Fatalf("set exit %d", code) + } + out, code := runConfig(t, "list") + if code != 0 || !strings.Contains(out, "terminal.view_mode_rules.page-status=done = preview\n") { + t.Fatalf("list (exit %d): %s", code, out) + } + out, _ = runConfig(t, "list", "--json") + if !strings.Contains(out, `"view_mode_rules.page-status=done": "preview"`) { + t.Fatalf("list --json: %s", out) + } +} + +func TestConfigGetViewModeRule(t *testing.T) { + isolatedCLI(t) + runConfig(t, "set", "terminal.view_mode_rules.kind=journal", "split") + out, code := runConfig(t, "get", "terminal.view_mode_rules.kind=journal") + if code != 0 || strings.TrimSpace(out) != "split" { + t.Fatalf("get (exit %d): %q", code, out) + } + if _, code := runConfig(t, "get", "terminal.view_mode_rules.kind=other"); code == 0 { + t.Fatal("get of an unset rule succeeded") + } +} + +func TestConfigSetViewModeRuleAddsAndChanges(t *testing.T) { + isolatedCLI(t) + path := config.ConfigTomlPath() + if err := os.WriteFile(path, []byte("[terminal]\nmath = \"latex\"\n\n[editor]\nword_wrap = false\n\n[custom]\nkeep = \"me\"\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, mode := range []string{"preview", "Edit"} { + if o, code := runConfig(t, "set", "terminal.view_mode_rules.page-status=done", mode); code != 0 { + t.Fatalf("set %s: exit %d: %s", mode, code, o) + } + } + body, _ := os.ReadFile(path) + for _, want := range []string{`math = "latex"`, "[editor]", "word_wrap = false", "[custom]", `keep = "me"`, `"page-status=done" = "edit"`} { + if !strings.Contains(string(body), want) { + t.Fatalf("missing %q in:\n%s", want, body) + } + } + if strings.Contains(string(body), "preview") { + t.Fatalf("old mode left behind:\n%s", body) + } + prefs, _, err := config.LoadPrefs() + if err != nil || len(prefs.ViewModeRules) != 1 { + t.Fatalf("reload: %+v %v", prefs.ViewModeRules, err) + } +} + +func TestConfigSetViewModeRuleRejectsBadMode(t *testing.T) { + isolatedCLI(t) + path := config.ConfigTomlPath() + original := "[terminal]\nmath = \"latex\"\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"set", "terminal.view_mode_rules.page-status=done", "reading"}, + {"set", "terminal.view_mode_rules.page-status", "preview"}, + {"set", "terminal.view_mode_rules.=done", "preview"}, + {"set", "terminal.view_mode_rules.status=", "preview"}, + } { + if _, code := runConfig(t, args...); code == 0 { + t.Fatalf("%v succeeded", args) + } + } + body, _ := os.ReadFile(path) + if string(body) != original { + t.Fatalf("config changed:\n%s", body) + } +} + +func TestConfigSetViewModeField(t *testing.T) { + isolatedCLI(t) + if _, code := runConfig(t, "set", "terminal.view_mode_field", "view-mode"); code != 0 { + t.Fatalf("exit %d", code) + } + prefs, _, err := config.LoadPrefs() + if err != nil || prefs.TerminalViewModeField != "view-mode" { + t.Fatalf("field: %q %v", prefs.TerminalViewModeField, err) + } + if _, code := runConfig(t, "set", "terminal.view_mode_field", ""); code != 0 { + t.Fatalf("clear exit %d", code) + } + prefs, _, err = config.LoadPrefs() + if err != nil || prefs.TerminalViewModeField != "" { + t.Fatalf("cleared field: %q %v", prefs.TerminalViewModeField, err) + } +} From 7428c7d723b88c70f0ca7f036eaefd09653058af Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:51:21 -0400 Subject: [PATCH 07/14] Feat(tui): view-mode settings and rules on the settings page `:settings` now shows and edits the frontmatter view-mode configuration, so it never needs config.toml opened by hand. `terminal.view_mode_field` appears with the other terminal settings and is edited like them; while it is empty its prompt suggests `view-mode` as an example rather than filling it in. Right after the terminal rows come the rules, as `terminal.view_mode_rules.= = `: the valid ones in the order they are matched, then the invalid ones, each ending in its reason, such as `(invalid: no "=")`. A rule shows its key as written in the file. Last comes an "(add rule)" row, which stays visible whatever the filter. Enter on the add row asks for the field, then the value, then offers edit, split and preview; nothing is written until a mode is picked, Esc at any step writes nothing, and an empty field, a field holding "=", an empty value or a rule that already exists in another case is refused. Enter on a rule offers the three modes and remove; `d` on a rule row also removes it, and the footer names `d` while one is selected. A rule with a bad mode can be fixed by picking a mode; one whose key is malformed offers only remove. Rules are changed and removed by their key as written, so hand-typed keys are found. After every change the preferences are reloaded and the rows refreshed. settingRow keeps its four fields: a rule row's key is the rule's key, and whether a row is a setting, a rule, an invalid rule or the add row is worked out from the row itself. Tests cover editing the field, rule order, invalid rules marked and ignored when a note opens, filtering, adding with every kind of bad input and cancel, changing, removing with the picker and with `d`, the footer hint, fixing a bad mode, and malformed rules. Co-Authored-By: Claude Opus 5.5 --- internal/tui/settings_controls.go | 170 +++++++- internal/tui/settings_viewmode_test.go | 547 +++++++++++++++++++++++++ internal/tui/views_hints.go | 6 +- 3 files changed, 719 insertions(+), 4 deletions(-) create mode 100644 internal/tui/settings_viewmode_test.go diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index 45f6646..79126ef 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -15,6 +15,38 @@ type settingRow struct { value any vault bool } + +type ruleRowKind int + +const ( + rowSetting ruleRowKind = iota + rowRule + rowInvalidRule + rowAddRule +) + +const viewModeRulesSection = "terminal.view_mode_rules" + +// addRuleValue is the value of the row that adds a rule. Its own type keeps +// that row apart from every rule, whatever key a rule is written under. +type addRuleValue struct{} + +func (addRuleValue) String() string { return "Enter to add a rule" } + +// kind tells plain settings from the view-mode rule rows. A rule row's key +// is the rule's key exactly as written in config.toml. +func (r settingRow) kind() ruleRowKind { + switch { + case r.section != viewModeRulesSection: + return rowSetting + case r.value == addRuleValue{}: + return rowAddRule + case r.reason() != "": + return rowInvalidRule + } + return rowRule +} + type settingsView struct { scroll, rows int lines []string @@ -34,7 +66,10 @@ func (v *settingsView) refresh(a *App) { scope = "vault" } line := scope + " · " + s.section + "." + s.key + " = " + fmt.Sprint(s.value) - if strings.Contains(strings.ToLower(line), strings.ToLower(v.filter)) { + if s.kind() == rowInvalidRule { + line += " (invalid: " + s.reason() + ")" + } + if s.kind() == rowAddRule || strings.Contains(strings.ToLower(line), strings.ToLower(v.filter)) { v.settings = append(v.settings, s) v.lines = append(v.lines, line) } @@ -52,6 +87,9 @@ func (v *settingsView) refresh(a *App) { } add(settingRow{section, key, tables[section][key], false}) } + if section == "terminal" { + v.addRuleRows(a, add) + } } if a.idx != nil { for _, p := range []struct { @@ -72,6 +110,115 @@ func (v *settingsView) refresh(a *App) { } v.list.clamp(len(v.settings)) } + +// reason is the short explanation for an invalid rule row. +func (r settingRow) reason() string { + _, reason := config.ParseViewModeRule(r.key, fmt.Sprint(r.value)) + return reason +} + +// addRuleRows lists the view-mode rules after the terminal rows: the valid +// ones in match order, then the invalid ones, then the row that adds a rule. +// The add row stays under every filter. +func (v *settingsView) addRuleRows(a *App, add func(settingRow)) { + for _, e := range config.ViewModeRuleEntries(a.prefs.ViewModeRules) { + add(settingRow{viewModeRulesSection, e.Key, e.Mode, false}) + } + add(settingRow{viewModeRulesSection, "(add rule)", addRuleValue{}, false}) +} + +// addRule asks for the frontmatter field, then the value, then the mode, and +// writes the rule only after the mode is picked. Esc at any step writes +// nothing. +func (v *settingsView) addRule(a *App) { + a.promptFor("View-mode rule: frontmatter field", "", "For example page-status · Esc cancels", func(a *App, field string) { + field = strings.TrimSpace(field) + if field == "" { + a.notifyError("Enter a frontmatter field") + return + } + if strings.Contains(field, "=") { + a.notifyError("The field cannot contain \"=\"") + return + } + a.promptFor("View-mode rule: value of "+field, "", "For example done · a list field matches any element · Esc cancels", func(a *App, value string) { + value = strings.TrimSpace(value) + if value == "" { + a.notifyError("Enter a value to match") + return + } + key := field + "=" + value + rule, _ := config.ParseViewModeRule(key, "edit") + for _, e := range config.ViewModeRuleEntries(a.prefs.ViewModeRules) { + if e.Valid && e.Rule.Key == rule.Key && e.Rule.Value == rule.Value { + a.notifyError("A rule for " + rule.Key + "=" + rule.Value + " already exists") + return + } + } + items := []paletteItem{} + for _, m := range config.ViewModeModes { + items = append(items, paletteItem{label: m, id: m}) + } + a.overlay = &palette{title: "Pane mode for " + key, items: items, filtered: items, onSelect: func(a *App, it paletteItem) { + if err := config.SetViewModeRule(key, it.id); err != nil { + a.notifyError(err.Error()) + return + } + if err := a.reloadPreferences(); err != nil { + a.notifyError(err.Error()) + } + v.refresh(a) + }} + }) + }) +} + +// selectedRuleRow is the rule row under the cursor, valid or invalid. +func (v *settingsView) selectedRuleRow() (settingRow, bool) { + if v.list.cursor < 0 || v.list.cursor >= len(v.settings) { + return settingRow{}, false + } + row := v.settings[v.list.cursor] + return row, row.kind() == rowRule || row.kind() == rowInvalidRule +} + +// applyRuleChange reloads preferences after a rule was written or removed and +// refreshes the rows. +func (v *settingsView) applyRuleChange(a *App, err error) { + if err != nil { + a.notifyError(err.Error()) + return + } + if err := a.reloadPreferences(); err != nil { + a.notifyError(err.Error()) + } + v.refresh(a) +} + +// removeRule deletes a rule by its key exactly as written in config.toml. +func (v *settingsView) removeRule(a *App, row settingRow) { + v.applyRuleChange(a, config.RemoveViewModeRule(row.key)) +} + +// editRule opens the mode palette for a rule row. A rule whose key is +// malformed cannot take a mode, so it only offers remove. +func (v *settingsView) editRule(a *App, row settingRow) { + items := []paletteItem{} + if _, reason := config.ParseViewModeRule(row.key, "edit"); reason == "" { + for _, m := range config.ViewModeModes { + items = append(items, paletteItem{label: m, id: m}) + } + } + items = append(items, paletteItem{label: "remove", id: "remove"}) + a.overlay = &palette{title: "Pane mode for " + row.key, items: items, filtered: items, onSelect: func(a *App, it paletteItem) { + if it.id == "remove" { + v.removeRule(a, row) + return + } + v.applyRuleChange(a, config.SetViewModeRule(row.key, it.id)) + }} +} + func (v *settingsView) render(a *App, w, h int, focused bool) string { if v.lines == nil { v.refresh(a) @@ -94,12 +241,25 @@ func (v *settingsView) handleKey(a *App, k vim.Key) bool { _ = a.showVersion(vim.ExCommand{Name: "version"}) return true } + if k.IsRune('d') { + if row, ok := v.selectedRuleRow(); ok { + v.removeRule(a, row) + } + return true + } if a.listNav(&v.list, k, len(v.settings), v.rows) { return true } if k.Is("enter") { if v.list.cursor < len(v.settings) { - v.edit(a, v.settings[v.list.cursor]) + switch row := v.settings[v.list.cursor]; row.kind() { + case rowSetting: + v.edit(a, row) + case rowAddRule: + v.addRule(a) + case rowRule, rowInvalidRule: + v.editRule(a, row) + } } return true } @@ -175,7 +335,11 @@ func (v *settingsView) edit(a *App, row settingRow) { a.overlay = &palette{title: row.section + "." + row.key, items: items, filtered: items, onSelect: func(a *App, it paletteItem) { save(it.id) }} return } - a.promptFor(row.section+"."+row.key, fmt.Sprint(row.value), "Saved immediately · Esc cancels", func(a *App, text string) { + help := "Saved immediately · Esc cancels" + if row.section == "terminal" && row.key == "view_mode_field" && strings.TrimSpace(fmt.Sprint(row.value)) == "" { + help = "Frontmatter field that sets the pane mode, for example view-mode · " + help + } + a.promptFor(row.section+"."+row.key, fmt.Sprint(row.value), help, func(a *App, text string) { if _, ok := row.value.(int); ok { n, err := strconv.Atoi(strings.TrimSpace(text)) if err != nil || n < 0 || n > 10000 { diff --git a/internal/tui/settings_viewmode_test.go b/internal/tui/settings_viewmode_test.go new file mode 100644 index 0000000..56562e9 --- /dev/null +++ b/internal/tui/settings_viewmode_test.go @@ -0,0 +1,547 @@ +package tui + +import ( + "os" + "slices" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/vim" +) + +func settingsLineIndex(t *testing.T, v *settingsView, line string) int { + t.Helper() + i := slices.Index(v.lines, line) + if i < 0 { + t.Fatalf("no settings row %q in:\n%s", line, strings.Join(v.lines, "\n")) + } + return i +} + +func writeRules(t *testing.T, a *App, rules map[string]string) { + t.Helper() + for k, m := range rules { + if err := config.UpdateConfig(func(doc map[string]any) error { + terminal, _ := doc["terminal"].(map[string]any) + if terminal == nil { + terminal = map[string]any{} + doc["terminal"] = terminal + } + table, _ := terminal["view_mode_rules"].(map[string]any) + if table == nil { + table = map[string]any{} + terminal["view_mode_rules"] = table + } + table[k] = m + return nil + }); err != nil { + t.Fatal(err) + } + } + if err := a.reloadPreferences(); err != nil { + t.Fatal(err) + } +} + +func TestSettingsEditsViewModeField(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + v := &settingsView{} + v.refresh(a) + v.list.cursor = settingsLineIndex(t, v, "user · terminal.view_mode_field = ") + if !v.handleKey(a, vim.Key{Name: "enter"}) { + t.Fatal("enter was not handled") + } + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("overlay = %T, want a prompt", a.overlay) + } + if !strings.Contains(p.help, "view-mode") { + t.Fatalf("prompt help %q does not suggest view-mode", p.help) + } + p.onSubmit(a, "view-mode") + data, err := os.ReadFile(config.ConfigTomlPath()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `view_mode_field = "view-mode"`) { + t.Fatalf("config.toml:\n%s", data) + } + if a.prefs.TerminalViewModeField != "view-mode" { + t.Fatalf("reloaded field = %q", a.prefs.TerminalViewModeField) + } + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("note opened in %q, want preview", got) + } + + v.refresh(a) + v.list.cursor = settingsLineIndex(t, v, "user · terminal.view_mode_field = view-mode") + v.handleKey(a, vim.Key{Name: "enter"}) + a.overlay.(*prompt).onSubmit(a, "") + if a.prefs.TerminalViewModeField != "" { + t.Fatalf("field after clearing = %q", a.prefs.TerminalViewModeField) + } + data, _ = os.ReadFile(config.ConfigTomlPath()) + if !strings.Contains(string(data), `view_mode_field = ""`) { + t.Fatalf("config.toml after clearing:\n%s", data) + } +} + +func TestSettingsListsViewModeRulesInOrder(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "Kind=Journal": "edit", "other=x": "split"}) + v := &settingsView{} + v.refresh(a) + var got []string + for _, l := range v.lines { + if strings.Contains(l, "terminal.view_mode_rules.") { + got = append(got, l) + } + } + want := []string{ + "user · terminal.view_mode_rules.Kind=Journal = edit", + "user · terminal.view_mode_rules.other=x = split", + "user · terminal.view_mode_rules.page-status=done = preview", + "user · terminal.view_mode_rules.(add rule) = Enter to add a rule", + } + if !slices.Equal(got, want) { + t.Fatalf("rule rows:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + // The rules follow the terminal rows and come before the vault rows. + last := slices.Index(v.lines, want[3]) + for i, l := range v.lines { + if strings.HasPrefix(l, "vault · ") && i < last { + t.Fatalf("vault row %q sits before the rule rows", l) + } + } + for _, i := range []int{0, 1, 2, 3} { + if i < 3 && v.settings[slices.Index(v.lines, want[i])].kind() != rowRule { + t.Fatalf("row %q is not a rule row", want[i]) + } + } +} + +func TestSettingsListsInvalidRulesMarked(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nkind: journal\n---\nbody\n") + a.prefs.DefaultPaneMode = "edit" + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "sideways", "status": "preview", "=x": "edit"}) + v := &settingsView{} + v.refresh(a) + var got []string + for _, l := range v.lines { + if strings.Contains(l, "terminal.view_mode_rules.") { + got = append(got, l) + } + } + want := []string{ + "user · terminal.view_mode_rules.page-status=done = preview", + `user · terminal.view_mode_rules.=x = edit (invalid: empty key)`, + "user · terminal.view_mode_rules.kind=journal = sideways (invalid: bad mode)", + `user · terminal.view_mode_rules.status = preview (invalid: no "=")`, + "user · terminal.view_mode_rules.(add rule) = Enter to add a rule", + } + if !slices.Equal(got, want) { + t.Fatalf("rule rows:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } + for _, l := range want[1:4] { + if v.settings[slices.Index(v.lines, l)].kind() != rowInvalidRule { + t.Fatalf("row %q is not marked invalid", l) + } + } + // A note that only an invalid rule matches opens as with the feature off. + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("note opened in %q, want edit", got) + } +} + +func TestSettingsFilterMatchesRulesAndKeepsAddRow(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "split"}) + add := "user · terminal.view_mode_rules.(add rule) = Enter to add a rule" + for _, tc := range []struct { + filter string + want []string + }{ + {"page-status", []string{"user · terminal.view_mode_rules.page-status=done = preview"}}, + {"journal", []string{"user · terminal.view_mode_rules.kind=journal = split"}}, + {"= split", []string{"user · terminal.view_mode_rules.kind=journal = split"}}, + {"no-such-text-anywhere", nil}, + } { + v := &settingsView{filter: tc.filter} + v.refresh(a) + want := append(slices.Clone(tc.want), add) + if !slices.Equal(v.lines, want) { + t.Fatalf("filter %q gave:\n%s\nwant:\n%s", tc.filter, strings.Join(v.lines, "\n"), strings.Join(want, "\n")) + } + } +} + +const addRuleRow = "user · terminal.view_mode_rules.(add rule) = Enter to add a rule" + +func openAddRule(t *testing.T, a *App) *settingsView { + t.Helper() + v := &settingsView{} + v.refresh(a) + v.list.cursor = settingsLineIndex(t, v, addRuleRow) + v.handleKey(a, vim.Key{Name: "enter"}) + if _, ok := a.overlay.(*prompt); !ok { + t.Fatalf("overlay = %T, want the field prompt", a.overlay) + } + return v +} + +func submitPrompt(t *testing.T, a *App, text string) { + t.Helper() + p, ok := a.overlay.(*prompt) + if !ok { + t.Fatalf("overlay = %T, want a prompt", a.overlay) + } + a.overlay = nil + p.onSubmit(a, text) +} + +func TestSettingsAddRuleWritesAfterModePick(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\npage-status: done\n---\nbody\n") + if err := config.SetValue("terminal", "math", "latex"); err != nil { + t.Fatal(err) + } + if err := config.SetValue("editor", "tab_size", 3); err != nil { + t.Fatal(err) + } + if err := a.reloadPreferences(); err != nil { + t.Fatal(err) + } + v := openAddRule(t, a) + submitPrompt(t, a, " page-status ") + submitPrompt(t, a, " done ") + pal, ok := a.overlay.(*palette) + if !ok { + t.Fatalf("overlay = %T, want the mode palette", a.overlay) + } + before, _ := os.ReadFile(config.ConfigTomlPath()) + if strings.Contains(string(before), "view_mode_rules") { + t.Fatalf("rule written before the mode was picked:\n%s", before) + } + for _, it := range pal.items { + if it.id == "preview" { + pal.onSelect(a, it) + } + } + data, _ := os.ReadFile(config.ConfigTomlPath()) + for _, want := range []string{"math", "tab_size", "view_mode_rules", `"page-status=done" = "preview"`} { + if !strings.Contains(string(data), want) { + t.Fatalf("config.toml lacks %q:\n%s", want, data) + } + } + if a.prefs.ViewModeRules["page-status=done"] != "preview" { + t.Fatalf("prefs rules = %v", a.prefs.ViewModeRules) + } + settingsLineIndex(t, v, "user · terminal.view_mode_rules.page-status=done = preview") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("note opened in %q, want preview", got) + } +} + +func TestSettingsAddRuleOffersOnlyThreeModes(t *testing.T) { + a, _ := newFeatureTestApp(t) + openAddRule(t, a) + submitPrompt(t, a, "page-status") + submitPrompt(t, a, "done") + pal, ok := a.overlay.(*palette) + if !ok { + t.Fatalf("overlay = %T, want the mode palette", a.overlay) + } + var got []string + for _, it := range pal.items { + got = append(got, it.id) + } + if !slices.Equal(got, []string{"edit", "split", "preview"}) { + t.Fatalf("palette items = %v", got) + } +} + +func TestSettingsAddRuleCancelWritesNothing(t *testing.T) { + a, _ := newFeatureTestApp(t) + if err := config.SetValue("terminal", "math", "latex"); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(config.ConfigTomlPath()) + esc := func() { + t.Helper() + a.overlay.handleKey(a, vim.KeyEsc) + if a.overlay != nil { + t.Fatalf("overlay %T survived esc", a.overlay) + } + } + // Esc at the field prompt, at the value prompt and at the mode palette. + openAddRule(t, a) + esc() + openAddRule(t, a) + submitPrompt(t, a, "page-status") + esc() + openAddRule(t, a) + submitPrompt(t, a, "page-status") + submitPrompt(t, a, "done") + esc() + after, _ := os.ReadFile(config.ConfigTomlPath()) + if string(before) != string(after) { + t.Fatalf("config.toml changed:\n%s\nwas:\n%s", after, before) + } +} + +func TestSettingsAddRuleRejectsBadInput(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview"}) + before, _ := os.ReadFile(config.ConfigTomlPath()) + for _, tc := range []struct{ field, value, want string }{ + {"", "", "field"}, + {" ", "", "field"}, + {"a=b", "", "="}, + {"page-status", "", "value"}, + {"page-status", "done", "already exists"}, + {"Page-Status", " DONE ", "already exists"}, + } { + a.message, a.messageErr = "", false + openAddRule(t, a) + submitPrompt(t, a, tc.field) + if tc.value != "" || tc.field == "page-status" || tc.field == "Page-Status" { + submitPrompt(t, a, tc.value) + } + if !a.messageErr || !strings.Contains(a.message, tc.want) { + t.Fatalf("%q/%q: message = %q (error %v), want error containing %q", tc.field, tc.value, a.message, a.messageErr, tc.want) + } + if a.overlay != nil { + t.Fatalf("%q/%q: overlay %T left open", tc.field, tc.value, a.overlay) + } + after, _ := os.ReadFile(config.ConfigTomlPath()) + if string(before) != string(after) { + t.Fatalf("%q/%q changed config.toml:\n%s", tc.field, tc.value, after) + } + } +} + +const ruleDoneRow = "user · terminal.view_mode_rules.page-status=done = preview" + +func selectRule(t *testing.T, a *App, line string) *settingsView { + t.Helper() + v := &settingsView{} + v.refresh(a) + v.list.cursor = settingsLineIndex(t, v, line) + return v +} + +func pickPalette(t *testing.T, a *App, id string) { + t.Helper() + pal, ok := a.overlay.(*palette) + if !ok { + t.Fatalf("overlay = %T, want a palette", a.overlay) + } + for _, it := range pal.items { + if it.id == id { + a.overlay = nil + pal.onSelect(a, it) + return + } + } + t.Fatalf("palette has no %q entry", id) +} + +func paletteIDs(t *testing.T, a *App) []string { + t.Helper() + pal, ok := a.overlay.(*palette) + if !ok { + t.Fatalf("overlay = %T, want a palette", a.overlay) + } + var ids []string + for _, it := range pal.items { + ids = append(ids, it.id) + } + return ids +} + +func configText(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(config.ConfigTomlPath()) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestSettingsChangeRuleMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\npage-status: done\n---\nbody\n") + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "split"}) + v := selectRule(t, a, ruleDoneRow) + v.handleKey(a, vim.Key{Name: "enter"}) + if got := paletteIDs(t, a); !slices.Equal(got, []string{"edit", "split", "preview", "remove"}) { + t.Fatalf("palette items = %v", got) + } + pickPalette(t, a, "edit") + if a.prefs.ViewModeRules["page-status=done"] != "edit" || a.prefs.ViewModeRules["kind=journal"] != "split" { + t.Fatalf("prefs rules = %v", a.prefs.ViewModeRules) + } + settingsLineIndex(t, v, "user · terminal.view_mode_rules.page-status=done = edit") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeEdit { + t.Fatalf("note opened in %q, want edit", got) + } +} + +func TestSettingsRemoveRuleFromPicker(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "split"}) + v := selectRule(t, a, ruleDoneRow) + v.handleKey(a, vim.Key{Name: "enter"}) + pickPalette(t, a, "remove") + if _, ok := a.prefs.ViewModeRules["page-status=done"]; ok || len(a.prefs.ViewModeRules) != 1 { + t.Fatalf("prefs rules = %v", a.prefs.ViewModeRules) + } + if slices.Contains(v.lines, ruleDoneRow) { + t.Fatal("removed rule still listed") + } + if !strings.Contains(configText(t), `"kind=journal" = "split"`) { + t.Fatalf("other rule lost:\n%s", configText(t)) + } +} + +func TestSettingsRemoveRuleWithKey(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview"}) + v := selectRule(t, a, ruleDoneRow) + if !v.handleKey(a, vim.Key{Rune: 'd'}) { + t.Fatal("d was not handled") + } + if len(a.prefs.ViewModeRules) != 0 { + t.Fatalf("prefs rules = %v", a.prefs.ViewModeRules) + } + if slices.Contains(v.lines, ruleDoneRow) { + t.Fatal("removed rule still listed") + } + if strings.Contains(configText(t), "view_mode_rules") { + t.Fatalf("empty rules table left in config.toml:\n%s", configText(t)) + } +} + +func TestSettingsRemoveKeyIgnoredOffRuleRows(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview"}) + before := configText(t) + v := &settingsView{} + v.refresh(a) + rows := slices.Clone(v.lines) + for i, l := range rows { + if strings.Contains(l, "terminal.view_mode_rules.page-status") { + continue + } + v.list.cursor = i + if !v.handleKey(a, vim.Key{Rune: 'd'}) { + t.Fatalf("d on %q was not consumed", l) + } + if a.overlay != nil { + t.Fatalf("d on %q opened %T", l, a.overlay) + } + } + if configText(t) != before { + t.Fatalf("config.toml changed:\n%s", configText(t)) + } + if !slices.Equal(v.lines, rows) { + t.Fatal("rows changed") + } +} + +func TestSettingsHintNamesRemoveOnRuleRow(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "sideways"}) + v := &settingsView{} + v.refresh(a) + for i, l := range v.lines { + v.list.cursor = i + got := false + for _, h := range v.hintPairs(a) { + if h.id == "key:d" && h.label == "remove rule" { + got = true + } + } + want := strings.Contains(l, "terminal.view_mode_rules.") && !strings.Contains(l, "(add rule)") + if got != want { + t.Fatalf("hint on %q: remove rule present = %v, want %v", l, got, want) + } + } +} + +func TestSettingsRemoveInvalidRule(t *testing.T) { + rows := map[string]string{ + "kind=journal": "user · terminal.view_mode_rules.kind=journal = sideways (invalid: bad mode)", + "status": `user · terminal.view_mode_rules.status = preview (invalid: no "=")`, + } + for key, row := range rows { + for _, how := range []string{"d", "picker"} { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"page-status=done": "preview", "kind=journal": "sideways", "status": "preview"}) + if err := config.SetValue("terminal", "math", "latex"); err != nil { + t.Fatal(err) + } + v := selectRule(t, a, row) + if how == "d" { + v.handleKey(a, vim.Key{Rune: 'd'}) + } else { + v.handleKey(a, vim.Key{Name: "enter"}) + pickPalette(t, a, "remove") + } + if _, ok := a.prefs.ViewModeRules[key]; ok { + t.Fatalf("%s/%s: entry still in prefs", key, how) + } + if slices.Contains(v.lines, row) { + t.Fatalf("%s/%s: row still listed", key, how) + } + text := configText(t) + if !strings.Contains(text, `"page-status=done" = "preview"`) || !strings.Contains(text, "math") { + t.Fatalf("%s/%s: other settings lost:\n%s", key, how, text) + } + if len(a.prefs.ViewModeRules) != 2 { + t.Fatalf("%s/%s: prefs rules = %v", key, how, a.prefs.ViewModeRules) + } + } + } +} + +func TestSettingsFixInvalidRuleMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nkind: journal\n---\nbody\n") + a.prefs.DefaultPaneMode = "edit" + writeRules(t, a, map[string]string{"kind=journal": "sideways"}) + v := selectRule(t, a, "user · terminal.view_mode_rules.kind=journal = sideways (invalid: bad mode)") + v.handleKey(a, vim.Key{Name: "enter"}) + if got := paletteIDs(t, a); !slices.Equal(got, []string{"edit", "split", "preview", "remove"}) { + t.Fatalf("palette items = %v", got) + } + pickPalette(t, a, "preview") + settingsLineIndex(t, v, "user · terminal.view_mode_rules.kind=journal = preview") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("note opened in %q, want preview", got) + } +} + +func TestSettingsMalformedRuleOffersOnlyRemove(t *testing.T) { + a, _ := newFeatureTestApp(t) + writeRules(t, a, map[string]string{"status": "preview", "=x": "edit"}) + for _, line := range []string{ + `user · terminal.view_mode_rules.status = preview (invalid: no "=")`, + "user · terminal.view_mode_rules.=x = edit (invalid: empty key)", + } { + v := selectRule(t, a, line) + v.handleKey(a, vim.Key{Name: "enter"}) + if got := paletteIDs(t, a); !slices.Equal(got, []string{"remove"}) { + t.Fatalf("%q palette items = %v", line, got) + } + a.overlay = nil + } +} diff --git a/internal/tui/views_hints.go b/internal/tui/views_hints.go index d483e54..831e743 100644 --- a/internal/tui/views_hints.go +++ b/internal/tui/views_hints.go @@ -34,7 +34,11 @@ func (v *helpView) hintPairs(a *App) []hintPair { } func (v *settingsView) hintPairs(a *App) []hintPair { - return []hintPair{{"nav.moveDown|nav.moveUp", "select"}, {"key:enter", "edit"}, {"key:/", "filter"}, {"key:v", "version details"}, {"key::config", "config file"}} + pairs := []hintPair{{"nav.moveDown|nav.moveUp", "select"}, {"key:enter", "edit"}} + if _, ok := v.selectedRuleRow(); ok { + pairs = append(pairs, hintPair{"key:d", "remove rule"}) + } + return append(pairs, hintPair{"key:/", "filter"}, hintPair{"key:v", "version details"}, hintPair{"key::config", "config file"}) } // Mouse wheel scrolling for the list views. From 2e2da5fa24219e04541844ded1a93d008426d954 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:19:10 -0400 Subject: [PATCH 08/14] Fix(config): a view-mode rule with an empty value is invalid A hand-written rule such as `"status=" = "edit"` was read as valid and listed in `:settings` as an ordinary rule, yet choosing a new mode for it there failed, because writing a rule already refused an empty value. The rule looked usable but could only be removed. An empty value, including an empty quoted one, now makes a rule invalid when it is read, the same as a missing `=` or an empty field. Such a rule never matches a note; `:settings` and `zn config list` show it with the reason "empty value", and its picker offers only remove. The check that runs before a rule is written is now the same parse, so reading and writing agree on what a valid rule is. Co-Authored-By: Claude Opus 5.5 --- internal/config/viewmode_rules.go | 19 +++++++++---------- internal/config/viewmode_rules_test.go | 20 +++++++++++++++----- internal/tui/settings_viewmode_test.go | 3 ++- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/internal/config/viewmode_rules.go b/internal/config/viewmode_rules.go index 5511ebc..b22b9d4 100644 --- a/internal/config/viewmode_rules.go +++ b/internal/config/viewmode_rules.go @@ -45,7 +45,8 @@ func normalizeViewModePart(s string) string { } // ParseViewModeRule validates one "=" entry and its mode. The -// first "=" splits key from value. On failure it returns a short reason. +// first "=" splits key from value; both must be non-empty, and the mode +// must be edit, split or preview. On failure it returns a short reason. func ParseViewModeRule(key, mode string) (ViewModeRule, string) { k, v, found := strings.Cut(key, "=") if !found { @@ -55,22 +56,20 @@ func ParseViewModeRule(key, mode string) (ViewModeRule, string) { if k == "" { return ViewModeRule{}, "empty key" } + v = normalizeViewModePart(v) + if v == "" { + return ViewModeRule{}, "empty value" + } m, ok := normalizeViewModeMode(mode) if !ok { return ViewModeRule{}, "bad mode" } - return ViewModeRule{Key: k, Value: normalizeViewModePart(v), Mode: m}, "" + return ViewModeRule{Key: k, Value: v, Mode: m}, "" } -// ValidateViewModeRule is the check writers run before storing a rule: the -// key must hold "=", a non-empty field and a non-empty value, and the mode -// must be edit, split or preview. +// ValidateViewModeRule is the check writers run before storing a rule. func ValidateViewModeRule(key, mode string) error { - rule, reason := ParseViewModeRule(key, mode) - if reason == "" && rule.Value == "" { - reason = "empty value" - } - if reason != "" { + if _, reason := ParseViewModeRule(key, mode); reason != "" { return &viewModeRuleError{reason} } return nil diff --git a/internal/config/viewmode_rules_test.go b/internal/config/viewmode_rules_test.go index 0339681..13cbda3 100644 --- a/internal/config/viewmode_rules_test.go +++ b/internal/config/viewmode_rules_test.go @@ -61,11 +61,21 @@ func TestInvalidViewModeRulesAreIgnoredButReported(t *testing.T) { } } -// An empty value part is a rule like any other when read from the file. -func TestViewModeRuleWithEmptyValueIsValid(t *testing.T) { - valid := ValidViewModeRules(map[string]string{"status=": "edit"}) - if len(valid) != 1 || valid[0].Key != "status" || valid[0].Value != "" { - t.Fatalf("got %+v", valid) +// A rule with nothing after "=" is invalid when read from the file: it is +// reported with its reason and never matches. +func TestViewModeRuleWithEmptyValueIsInvalid(t *testing.T) { + raw := map[string]string{"status=": "edit", `status=""`: "preview"} + if valid := ValidViewModeRules(raw); len(valid) != 0 { + t.Fatalf("empty-value rules must not be valid, got %+v", valid) + } + entries := ViewModeRuleEntries(raw) + if len(entries) != 2 { + t.Fatalf("want both entries reported, got %+v", entries) + } + for _, e := range entries { + if e.Valid || e.Reason != "empty value" { + t.Fatalf("entry %q: want invalid with reason %q, got %+v", e.Key, "empty value", e) + } } } diff --git a/internal/tui/settings_viewmode_test.go b/internal/tui/settings_viewmode_test.go index 56562e9..265858b 100644 --- a/internal/tui/settings_viewmode_test.go +++ b/internal/tui/settings_viewmode_test.go @@ -532,10 +532,11 @@ func TestSettingsFixInvalidRuleMode(t *testing.T) { func TestSettingsMalformedRuleOffersOnlyRemove(t *testing.T) { a, _ := newFeatureTestApp(t) - writeRules(t, a, map[string]string{"status": "preview", "=x": "edit"}) + writeRules(t, a, map[string]string{"status": "preview", "=x": "edit", "kind=": "split"}) for _, line := range []string{ `user · terminal.view_mode_rules.status = preview (invalid: no "=")`, "user · terminal.view_mode_rules.=x = edit (invalid: empty key)", + "user · terminal.view_mode_rules.kind= = split (invalid: empty value)", } { v := selectRule(t, a, line) v.handleKey(a, vim.Key{Name: "enter"}) From 6e05399df908f9ee1b96b4ea5b6ce4d5a4c7f6fa Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:21:07 -0400 Subject: [PATCH 09/14] Docs: README and zn tui help describe frontmatter view modes The README gains a section on opening notes in a pane mode from their frontmatter: `[terminal] view_mode_field`, off by default with `view-mode` suggested, the `[terminal.view_mode_rules]` format with a `page-status=done` example, what makes a rule invalid and how to remove one, the order in which the field, the rules, the remembered and keep-across modes, restored tabs, new notes and `:ref` win, and how to edit both in `:settings` or with `zn config`. It says only zn tui reads them. The settings table lists both under `[terminal]`. zn tui's help adds a row for the field and the rules next to the pane mode keys, and says a manual mode switch always wins over frontmatter. Co-Authored-By: Claude Opus 5.5 --- README.md | 59 +++++++++++++++++++++++++++++++++++++++- internal/tui/helpdata.go | 3 +- internal/tui/tui_test.go | 11 ++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b5a6785..8e49f03 100644 --- a/README.md +++ b/README.md @@ -717,6 +717,63 @@ files by absolute path, so under `latex` a note can show in the preview the contents of any file you can read. Leave latex off for vaults you do not trust. +### Open notes in a pane mode from their frontmatter + +By default a note opens in the mode `editor.default_view_mode` gives (or the +mode you last chose, with `keep_view_mode_across_notes`). Two `[terminal]` +settings, both off by default, let a note's own frontmatter pick the mode when +it opens in a new tab: + +- **`view_mode_field`** names a frontmatter field. It is empty by default; + `view-mode` is a good name. A note with `view-mode: preview` (or `edit`, or + `split`) opens in that mode. The key and value are not case sensitive. A + list, an empty value or any other word is ignored. +- **`[terminal.view_mode_rules]`** maps a frontmatter `key=value` pair to a mode. + A list field matches when any element equals the value. + +```toml +[terminal] +view_mode_field = "view-mode" + +[terminal.view_mode_rules] +"page-status=done" = "preview" +"kind=journal" = "edit" +``` + +Rules are written `"=" = ""`, where the mode is `edit`, +`split` or `preview`. Keys and values are trimmed, unquoted and compared in +lower case. A rule is invalid, and ignored, when it has no `=`, an empty key, +an empty value or a mode other than those three. Invalid rules never stop zn +from starting; `:settings` lists them marked with their reason (`no "="`, +`empty key`, `empty value` or `bad mode`), and `d` or the mode picker's +`remove` deletes them. + +Precedence: + +- A valid `view_mode_field` value beats the rules. Among the rules, the first + one in sorted order of the lower-cased `key=value` wins, so + `kind=journal` is tried before `page-status=done`. +- Frontmatter beats the remembered mode and `keep_view_mode_across_notes`. + Applying it never changes either of them; a mode you switch to yourself + still does. +- A tab restored from the saved session keeps its saved mode. +- A note you have just created opens in the editor, whatever its frontmatter + says. Reopening it later follows the frontmatter. +- The pane opened by `:ref` stays in preview. +- Switching to a tab that is already open never changes its mode, and saving + new frontmatter does not either. + +Edit them in `:settings` (the `terminal.view_mode_field` row, and the +`terminal.view_mode_rules` rows with their add row) or with `zn config`: + +```bash +zn config set terminal.view_mode_field view-mode +zn config set terminal.view_mode_rules.page-status=done preview +``` + +Only `zn tui` reads these settings. zn never writes them into the vault, and +the desktop app ignores them. + ### Vaults and servers inside the app `:vault` (or `Space v`) lists every saved vault and server, with entries to @@ -810,7 +867,7 @@ layout choices do not change these terminal layouts. | `[view]` | `tasks_view_mode`, `kanban_group_by`, `kanban_statuses`, `note_sort_order` | | `[appearance]` | `theme_family`, `theme_mode`, `theme_id` | | `[tweaks]` | `accent = "#ff3b30"`: the desktop's Quick tweaks colors | -| `[terminal]` | `mouse`, `preview_style`, `images`, `theme`, `math`, `render_timeout`, `render_concurrency` (terminal only) | +| `[terminal]` | `mouse`, `preview_style`, `images`, `theme`, `math`, `render_timeout`, `render_concurrency`, `view_mode_field` and `[terminal.view_mode_rules]` (terminal only) | | `[keymaps]` | `"action.id" = "keys"`, or `""` to unbind; `:keymaps` lists the ids | | `[kanban_column_titles]` | `review = "In Review"` | | `[saved_filters]` | `Important = "!high"` | diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index dcb1498..24bac3f 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -31,6 +31,7 @@ func helpLines(a *App, filter string) []string { "Alt+.\tZen mode (editor only)", "Alt+1 … Alt+9\tJump to tab N", "Alt+E / Alt+S / Alt+P\tEditor, split (editor + preview) or preview mode for the tab", + "[terminal] view_mode_field\tOff by default. A frontmatter field (view-mode is a good name) whose value edit, split or preview sets the mode of a note opened in a new tab; [terminal.view_mode_rules] \"key=value\" = \"mode\" does the same for any field (the field wins). A new note opens in edit; a restored tab and :ref keep theirs", "Alt+Z\tToggle word wrap", "Alt+Left / Alt+Right\tBack and forward through the jump list", "Ctrl+P\tSearch notes (Enter opens; with no match Enter creates the note; Ctrl+D trashes the selected one)", @@ -119,7 +120,7 @@ func helpLines(a *App, filter string) []string { ":template :insert \tTemplates", ":newtask :filter \tTasks", ":outline :connections :calendar :closepanel :sidebar :zen\tPanels", - ":editmode :splitmode :previewmode\tTab view mode", + ":editmode :splitmode :previewmode\tTab view mode (a manual switch always wins over the note's frontmatter view mode)", ":fold :unfold :foldall :unfoldall :format :wrap :nu :theme :vim\tEditor state", ":db :dbnew \tDatabases", ":bind :unbind :keymaps\tPersistent keymaps; select an action in :keymaps to change its binding", diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 1793b08..a40a3e2 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -252,6 +252,17 @@ func TestHelpMentionsLatexSettings(t *testing.T) { } } +func TestHelpMentionsFrontmatterViewMode(t *testing.T) { + a := &App{} + a.keymap = newTestResolver() + help := strings.Join(helpLines(a, ""), "\n") + for _, want := range []string{"view_mode_field", "[terminal.view_mode_rules]", "frontmatter"} { + if !contains(help, want) { + t.Errorf("help does not mention %q", want) + } + } +} + // The help row keyed `[terminal] math` names the tools latex needs. func TestHelpMathEntryNamesTools(t *testing.T) { a := &App{} From 21ea0592e69bf22b7ba6d53804d29350e016b5d4 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:26:53 -0400 Subject: [PATCH 10/14] Test(tui): frontmatter view modes write nothing to the vault Two end-to-end tests guard the feature as a whole. One opens notes in frontmatter modes, lets a slow read land, switches, splits and closes tabs, and adds and removes a rule from `:settings`, then checks that every file in the vault, `.zennotes` included, is byte-for-byte as it was: the configuration lives only in zn's own config.toml. The other adds a rule in `:settings`, opens a matching note in that mode, changes the rule's mode, and reopens the note in the new one. Co-Authored-By: Claude Opus 5.5 --- internal/tui/settings_viewmode_test.go | 24 +++++++++ internal/tui/viewmode_open_test.go | 67 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/internal/tui/settings_viewmode_test.go b/internal/tui/settings_viewmode_test.go index 265858b..a424420 100644 --- a/internal/tui/settings_viewmode_test.go +++ b/internal/tui/settings_viewmode_test.go @@ -546,3 +546,27 @@ func TestSettingsMalformedRuleOffersOnlyRemove(t *testing.T) { a.overlay = nil } } + +func TestSettingsRuleThenOpenFollowsIt(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\npage-status: done\n---\nbody\n") + a.prefs.DefaultPaneMode = "edit" + + v := openAddRule(t, a) + submitPrompt(t, a, "page-status") + submitPrompt(t, a, "done") + pickPalette(t, a, "preview") + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("first open in %q, want preview", got) + } + + v.list.cursor = settingsLineIndex(t, v, ruleDoneRow) + v.handleKey(a, vim.Key{Name: "enter"}) + pickPalette(t, a, "split") + a.closeTab(a.activePane, a.activePane.findTab("n.md")) + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("reopen in %q, want split", got) + } +} diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index 5275d69..77c0ea3 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -11,6 +11,7 @@ import ( "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/templates" "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" ) const fmPreviewNote = "---\nview-mode: preview\npage-status: done\n---\n# Body\n" @@ -569,3 +570,69 @@ func TestReopenedNewNoteFollowsFrontmatter(t *testing.T) { t.Fatalf("reopened: %q, want preview", got) } } + +// vaultSnapshot maps every file under root, hidden directories included, to its bytes. +func vaultSnapshot(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + rel, _ := filepath.Rel(root, p) + out[rel] = string(data) + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestViewModeFeatureWritesNoVaultFiles(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", fmPreviewNote) + writeVaultNote(t, root, "m.md", "---\nkind: journal\n---\nbody\n") + writeVaultNote(t, root, "plain.md", "# plain\n") + enableViewModeField(a) + a.prefs.ViewModeRules = map[string]string{"kind=journal": "split"} + before := vaultSnapshot(t, root) + + a.deferReads = true + for _, name := range []string{"n.md", "m.md", "plain.md"} { + a.openNote(name, true) + loadDeferred(t, a, name) + } + a.setPaneMode(modeSplit) + a.splitPane(true) + a.setPaneMode(modeEdit) + a.openNote("n.md", true) + a.closeTab(a.activePane, a.activePane.findTab("n.md")) + + v := openAddRule(t, a) + submitPrompt(t, a, "page-status") + submitPrompt(t, a, "done") + pickPalette(t, a, "preview") + v.list.cursor = settingsLineIndex(t, v, ruleDoneRow) + v.handleKey(a, vim.Key{Rune: 'd'}) + if _, ok := a.prefs.ViewModeRules["page-status=done"]; ok { + t.Fatalf("rules after add and remove = %v", a.prefs.ViewModeRules) + } + + after := vaultSnapshot(t, root) + if len(after) != len(before) { + t.Fatalf("vault files %d, want %d", len(after), len(before)) + } + for name, body := range before { + if after[name] != body { + t.Fatalf("%s changed:\n%s\nwas:\n%s", name, after[name], body) + } + } + if _, ok := after[filepath.Join(".zennotes", "vault.json")]; ok { + t.Fatal(".zennotes/vault.json appeared") + } +} From 0b106bb15db88d3a4019af23da39b2ad80249124 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:38:43 -0400 Subject: [PATCH 11/14] Test(tui): notes no rule or field matches open as with the feature off Three cases were covered only below the open path or not at all. A note whose frontmatter matches nothing (a bad field value, a list in the field, or no matching rule) now has a test that it opens exactly as it would with the feature off, against a preview default, a remembered mode and the keep-across mode, before and after a slow read. Invalid rules are shown to be ignored when a note opens while a valid rule beside them still applies, and an empty field is shown to leave the rules working after a slow read. A last test keeps the README's description of the settings from going missing. Co-Authored-By: Claude Opus 5.5 --- internal/tui/viewmode_gaps_test.go | 103 +++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 internal/tui/viewmode_gaps_test.go diff --git a/internal/tui/viewmode_gaps_test.go b/internal/tui/viewmode_gaps_test.go new file mode 100644 index 0000000..97b2cf8 --- /dev/null +++ b/internal/tui/viewmode_gaps_test.go @@ -0,0 +1,103 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// With the feature on, a note whose frontmatter gives no mode opens exactly as +// it would with the feature off, before and after its body loads. +func TestNoMatchingFrontmatterOpensAsFeatureOff(t *testing.T) { + notes := map[string]string{ + "none.md": "# plain\n", + "unrelated.md": "---\ntitle: x\ntags: [a]\n---\nbody\n", + "miss.md": "---\npage-status: in-progress\n---\nbody\n", + "badfield.md": "---\nview-mode: reading\n---\nbody\n", + "listfield.md": "---\nview-mode: [preview]\n---\nbody\n", + } + for name, body := range notes { + for _, tc := range []struct { + label string + setup func(a *App) + want paneMode + }{ + {"default preview", func(a *App) { a.prefs.DefaultPaneMode = "preview" }, modePreview}, + {"remembered split", func(a *App) { a.noteModes[name] = modeSplit }, modeSplit}, + {"keep across split", func(a *App) { a.prefs.RetainViewMode = true; a.prefs.DefaultPaneMode = "split" }, modeSplit}, + } { + t.Run(name+"/"+tc.label, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, name, body) + enableViewModeField(a) + a.prefs.ViewModeRules = map[string]string{"page-status=done": "edit"} + tc.setup(a) + a.deferReads = true + a.openNote(name, true) + if got := modeOf(t, a, name); got != tc.want { + t.Fatalf("before load: %q, want %q", got, tc.want) + } + loadDeferred(t, a, name) + if got := modeOf(t, a, name); got != tc.want { + t.Fatalf("after load: %q, want %q", got, tc.want) + } + }) + } + } +} + +// An invalid rule never applies, and the valid rules still do. +func TestInvalidRuleIgnoredOnOpenValidRuleApplies(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nkind: journal\npage-status: done\n---\nbody\n") + a.prefs.DefaultPaneMode = "edit" + a.prefs.ViewModeRules = map[string]string{ + "kind=journal": "reading", + "no-equals": "preview", + "=x": "preview", + "page-status=done": "split", + } + a.openNote("n.md", true) + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("mode %q, want split", got) + } +} + +// An empty field turns off only the field: a rule still applies after a +// deferred read. +func TestEmptyFieldKeepsRulesOnDeferredOpen(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: edit\npage-status: done\n---\nbody\n") + a.prefs.TerminalViewModeField = "" + a.prefs.ViewModeRules = map[string]string{"page-status=done": "preview"} + a.deferReads = true + a.openNote("n.md", true) + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q, want preview", got) + } +} + +// The README documents the settings a reader looks up for per-note view modes. +func TestReadmeDocumentsFrontmatterViewMode(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "README.md")) + if err != nil { + t.Fatal(err) + } + readme := string(raw) + for _, want := range []string{ + "terminal.view_mode_field", + "view-mode", + "[terminal.view_mode_rules]", + `"page-status=done" = "preview"`, + "keep_view_mode_across_notes", + } { + if !strings.Contains(readme, want) { + t.Errorf("README does not mention %q", want) + } + } + if !strings.Contains(readme, "Only `zn tui` reads these settings") { + t.Error("README does not say that only zn tui reads these settings") + } +} From ce02956108435107bc268a0c39b40373e080c0c4 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:48:05 -0400 Subject: [PATCH 12/14] Fix(tui): a sidebar peek stays in preview and a reopened tab follows frontmatter Two ways of opening a note set the tab's mode themselves right after opening it, and with frontmatter view modes on, a slow read of the note could then land and replace that mode. Peeking a note from the sidebar asks for preview, as `:ref` does; the frontmatter could then switch the peek to edit or split. Both now set their mode through one helper that also stops the frontmatter from taking it back. Reopening a closed tab put back the mode the tab was closed in, and the frontmatter replaced it only when the read happened to be slow. A reopened note is now treated like any note opened in a new tab: its frontmatter mode wins whether the read is quick or slow, and the closed tab's mode stands only when the frontmatter asks for none. Tests cover a peek of a note whose frontmatter asks for edit or split, and reopening a closed tab with a quick and a slow read, with and without a frontmatter mode. Co-Authored-By: Claude Opus 5.5 --- internal/tui/commands_parity.go | 3 +- internal/tui/panes.go | 6 +++ internal/tui/sidebar.go | 5 +- internal/tui/viewmode_frontmatter.go | 11 ++++ internal/tui/viewmode_open_test.go | 80 ++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index 7898ae7..d66c845 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -726,8 +726,7 @@ func (a *App) openReference(path string) { if t == nil { return } - t.mode = modePreview - t.frontmatterPending = false + forceTabMode(t, modePreview) if !t.pinned { a.togglePin(p, p.active) } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index f5b45fa..e866ddf 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -333,9 +333,15 @@ func (a *App) reopenClosedTab() { a.openVirtualByPath(last.path) return } + reused := a.activePane != nil && a.activePane.findTab(last.path) >= 0 a.openNote(last.path, true) if t := a.activePane.activeTab(); t != nil { t.mode = last.mode + // A reopened note follows its frontmatter like any note opened in a + // new tab; the closed tab's mode stands only when it asks for none. + if !reused { + a.watchFrontmatterMode(t) + } } } diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 141e91b..17f1de0 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -584,10 +584,7 @@ func (s *sidebarState) handleKey(a *App, k vim.Key) { if r := s.row(); r != nil && r.kind == "note" { a.openNote(r.path, false) if t := a.activeTab(); t != nil { - t.mode = modePreview - if t.preview == nil { - t.preview = &previewState{} - } + forceTabMode(t, modePreview) } } default: diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go index 2867333..8848165 100644 --- a/internal/tui/viewmode_frontmatter.go +++ b/internal/tui/viewmode_frontmatter.go @@ -101,6 +101,17 @@ func (a *App) applyFrontmatterMode(buf *noteBuffer) { a.markSessionDirty() } +// forceTabMode sets a tab's mode for an explicit request, such as a +// reference or a peek. The note's frontmatter, still to be read, will not +// override it. +func forceTabMode(t *tab, mode paneMode) { + t.mode = mode + t.frontmatterPending = false + if mode != modeEdit && t.preview == nil { + t.preview = &previewState{} + } +} + // markNewNote records that path was just created and is about to be opened, // so the open starts in the editor instead of taking a mode from frontmatter. func (a *App) markNewNote(path string) { diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index 77c0ea3..840e7a1 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -636,3 +636,83 @@ func TestViewModeFeatureWritesNoVaultFiles(t *testing.T) { t.Fatal(".zennotes/vault.json appeared") } } + +// Peeking a note from the sidebar asks for preview; a slow read of the note's +// frontmatter must not take that back. +func TestSidebarPeekStaysPreview(t *testing.T) { + for _, fm := range []string{"edit", "split"} { + t.Run(fm, func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: "+fm+"\n---\nbody\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + a.sidebar.rebuild(a) + enableViewModeField(a) + found := false + for i, row := range a.sidebar.rows { + if row.path == "n.md" { + a.sidebar.cursor = i + found = true + break + } + } + if !found { + t.Fatal("fixture note is absent from the sidebar") + } + a.focus = focusSidebar + a.deferReads = true + a.sidebar.handleKey(a, vim.Key{Rune: 'p'}) + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode before load %q, want preview", got) + } + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode after load %q, want preview", got) + } + }) + } +} + +// Reopening a closed tab is an ordinary open in a new tab: the frontmatter +// mode wins over the mode the tab was closed in, however the read lands. +func TestReopenedClosedTabFollowsFrontmatter(t *testing.T) { + for _, deferred := range []bool{false, true} { + t.Run(map[bool]string{false: "sync", true: "deferred"}[deferred], func(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "---\nview-mode: preview\n---\nbody\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + enableViewModeField(a) + a.openNote("n.md", true) + a.setPaneMode(modeSplit) + a.closeTab(a.activePane, a.activePane.findTab("n.md")) + if len(tabsOf(a, "n.md")) != 0 { + t.Fatal("tab did not close") + } + a.deferReads = deferred + a.reopenClosedTab() + if deferred { + loadDeferred(t, a, "n.md") + } + if got := modeOf(t, a, "n.md"); got != modePreview { + t.Fatalf("mode %q, want preview from frontmatter", got) + } + }) + } +} + +// A reopened note whose frontmatter asks for nothing keeps the mode its tab +// was closed in. +func TestReopenedClosedTabWithoutFrontmatterKeepsMode(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "n.md", "# plain\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + enableViewModeField(a) + a.openNote("n.md", true) + a.setPaneMode(modeSplit) + a.closeTab(a.activePane, a.activePane.findTab("n.md")) + a.deferReads = true + a.reopenClosedTab() + loadDeferred(t, a, "n.md") + if got := modeOf(t, a, "n.md"); got != modeSplit { + t.Fatalf("mode %q, want split", got) + } +} From 8afc2adc7c99927b24b278f68ecb4f24be487700 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sat, 31 Oct 2026 22:51:30 -0400 Subject: [PATCH 13/14] Fix(config): rules differing only in case, or with a non-string mode Two hand-edited or command-line cases slipped past the rule handling. `zn config set terminal.view_mode_rules.Page-Status=done split` added a second entry next to an existing `page-status=done`, although both are the same rule: the new one then won the tie, and `:settings` listed two rows for one rule. Writing a rule now looks for an entry with the same field and value, compared the way rules are matched, in any case or quoting and whether or not its mode is valid, and changes that entry under the key it already has. A rule such as `"a=b" = 1` or `= true` was dropped while loading, so it was ignored but also missing from `:settings` and `zn config list`, where invalid rules are shown with a reason and can be removed. Such a mode is now kept as text, so the rule shows up as invalid with "bad mode" and can be removed like any other. Co-Authored-By: Claude Opus 5.5 --- internal/cli/config_viewmode_case_test.go | 29 ++++++++++++ internal/config/prefs.go | 12 ++++- internal/config/prefs_write.go | 21 ++++++++- internal/config/viewmode_edgecases_test.go | 54 ++++++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 internal/cli/config_viewmode_case_test.go create mode 100644 internal/config/viewmode_edgecases_test.go diff --git a/internal/cli/config_viewmode_case_test.go b/internal/cli/config_viewmode_case_test.go new file mode 100644 index 0000000..b0cfbfe --- /dev/null +++ b/internal/cli/config_viewmode_case_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "maps" + "os" + "testing" + + "github.com/ZenNotes/tui/internal/config" +) + +// `zn config set` on a rule that differs from an existing one only in case +// changes the existing rule instead of adding a duplicate. +func TestConfigSetViewModeRuleCaseVariantChangesExisting(t *testing.T) { + isolatedCLI(t) + path := config.ConfigTomlPath() + if err := os.WriteFile(path, []byte("[terminal.view_mode_rules]\n\"page-status=done\" = \"preview\"\n"), 0o644); err != nil { + t.Fatal(err) + } + if o, code := runConfig(t, "set", "terminal.view_mode_rules.Page-Status=done", "split"); code != 0 { + t.Fatalf("set: exit %d: %s", code, o) + } + prefs, _, err := config.LoadPrefs() + if err != nil { + t.Fatal(err) + } + if want := map[string]string{"page-status=done": "split"}; !maps.Equal(prefs.ViewModeRules, want) { + t.Fatalf("rules %v, want %v", prefs.ViewModeRules, want) + } +} diff --git a/internal/config/prefs.go b/internal/config/prefs.go index 6dcb98e..83f72e9 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "strings" @@ -245,7 +246,16 @@ func applyPrefs(p *Prefs, doc map[string]any) { setString(terminal, "math", &p.TerminalMath) setString(terminal, "view_mode_field", &p.TerminalViewModeField) if rules, ok := terminal["view_mode_rules"].(map[string]any); ok { - p.ViewModeRules = mergeStringTable(map[string]string{}, rules) + p.ViewModeRules = map[string]string{} + for k, v := range rules { + // A mode that is not a string is kept as text, so the rule is + // listed as invalid and can be removed instead of vanishing. + s, isString := v.(string) + if !isString { + s = fmt.Sprint(v) + } + p.ViewModeRules[k] = s + } } setInt(terminal, "render_timeout", &p.RenderTimeout) setInt(terminal, "render_concurrency", &p.RenderConcurrency) diff --git a/internal/config/prefs_write.go b/internal/config/prefs_write.go index 37178b3..f22d403 100644 --- a/internal/config/prefs_write.go +++ b/internal/config/prefs_write.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" @@ -35,18 +36,34 @@ func SetValue(section, key string, value any) error { } // SetViewModeRule adds or changes one [terminal.view_mode_rules] entry, -// keeping every other key and table. Rules already in the file that are -// malformed never block the write. +// keeping every other key and table. An entry already in the file for the +// same field and value, written in any case or quoting and valid or not, +// is changed in place instead of gaining a duplicate. Rules already in the +// file that are malformed never block the write. func SetViewModeRule(key, mode string) error { if err := ValidateViewModeRule(key, mode); err != nil { return err } + want, _ := ParseViewModeRule(key, mode) mode = strings.ToLower(strings.TrimSpace(mode)) return UpdateConfig(func(doc map[string]any) error { terminal, rules, err := viewModeTables(doc, true) if err != nil { return err } + if _, exact := rules[key]; !exact { + existing := make([]string, 0, len(rules)) + for k := range rules { + existing = append(existing, k) + } + sort.Strings(existing) + for _, k := range existing { + if r, reason := ParseViewModeRule(k, "edit"); reason == "" && r.Key == want.Key && r.Value == want.Value { + key = k + break + } + } + } rules[key] = mode terminal["view_mode_rules"] = rules doc["terminal"] = terminal diff --git a/internal/config/viewmode_edgecases_test.go b/internal/config/viewmode_edgecases_test.go new file mode 100644 index 0000000..7bf8362 --- /dev/null +++ b/internal/config/viewmode_edgecases_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "reflect" + "testing" +) + +// A hand-edited rule whose mode is not a string is kept, as an invalid rule +// that can be seen and removed, instead of disappearing on load. +func TestNonStringViewModeRuleIsKeptInvalid(t *testing.T) { + writeConfigFile(t, "[terminal.view_mode_rules]\n\"a=b\" = 1\n\"c=d\" = true\n\"e=f\" = \"edit\"\n") + p, _, err := LoadPrefs() + if err != nil { + t.Fatal(err) + } + if len(p.ViewModeRules) != 3 { + t.Fatalf("rules %v, want all three entries", p.ViewModeRules) + } + for _, e := range ViewModeRuleEntries(p.ViewModeRules) { + switch e.Key { + case "a=b", "c=d": + if e.Valid || e.Reason != "bad mode" { + t.Fatalf("%q: want invalid with bad mode, got %+v", e.Key, e) + } + case "e=f": + if !e.Valid { + t.Fatalf("%q: want valid, got %+v", e.Key, e) + } + } + } + if err := RemoveViewModeRule("a=b"); err != nil { + t.Fatal(err) + } + rules := ruleTable(readConfigDoc(t)) + if _, ok := rules["a=b"]; ok || len(rules) != 2 { + t.Fatalf("after remove: %v", rules) + } +} + +// Setting a rule that differs from an existing entry only in case or quoting +// changes that entry instead of adding a second one, even when it is invalid. +func TestSetViewModeRuleChangesCaseVariantInPlace(t *testing.T) { + writeConfigFile(t, "[terminal.view_mode_rules]\n\"page-status=done\" = \"preview\"\n\"Kind=Journal\" = \"sideways\"\n") + if err := SetViewModeRule("Page-Status=DONE", "split"); err != nil { + t.Fatal(err) + } + if err := SetViewModeRule(`kind="journal"`, "edit"); err != nil { + t.Fatal(err) + } + want := map[string]any{"page-status=done": "split", "Kind=Journal": "edit"} + if got := ruleTable(readConfigDoc(t)); !reflect.DeepEqual(got, want) { + t.Fatalf("rules %v, want %v", got, want) + } +} From abe78991669b6b0899f37fc20d81968649b91eb5 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Sun, 1 Nov 2026 06:53:39 -0500 Subject: [PATCH 14/14] Fix(tui): reopening a closed tab whose note is gone leaves the active tab alone When the note behind a closed tab had been deleted, reopening it opened nothing, but the closed tab's mode was still written to whichever tab was active, and with frontmatter view modes on that tab was then given its own frontmatter mode, replacing one the user had picked. The reopened mode is now applied only to the reopened note's tab. The README's list of what wins now says that a reopened tab follows its frontmatter, and that a sidebar peek stays in preview like `:ref`. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- internal/tui/panes.go | 2 +- internal/tui/viewmode_open_test.go | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8e49f03..890ece3 100644 --- a/README.md +++ b/README.md @@ -759,7 +759,9 @@ Precedence: - A tab restored from the saved session keeps its saved mode. - A note you have just created opens in the editor, whatever its frontmatter says. Reopening it later follows the frontmatter. -- The pane opened by `:ref` stays in preview. +- Reopening a closed tab follows the frontmatter too; the mode the tab was + closed in comes back only when the frontmatter asks for none. +- The pane opened by `:ref`, and a peek from the sidebar, stay in preview. - Switching to a tab that is already open never changes its mode, and saving new frontmatter does not either. diff --git a/internal/tui/panes.go b/internal/tui/panes.go index e866ddf..441f3f9 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -335,7 +335,7 @@ func (a *App) reopenClosedTab() { } reused := a.activePane != nil && a.activePane.findTab(last.path) >= 0 a.openNote(last.path, true) - if t := a.activePane.activeTab(); t != nil { + if t := a.activePane.activeTab(); t != nil && t.path == last.path { t.mode = last.mode // A reopened note follows its frontmatter like any note opened in a // new tab; the closed tab's mode stands only when it asks for none. diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go index 840e7a1..acebed7 100644 --- a/internal/tui/viewmode_open_test.go +++ b/internal/tui/viewmode_open_test.go @@ -716,3 +716,24 @@ func TestReopenedClosedTabWithoutFrontmatterKeepsMode(t *testing.T) { t.Fatalf("mode %q, want split", got) } } + +// Reopening a closed tab whose note has since gone leaves the active tab's +// mode alone. +func TestReopenedDeletedNoteLeavesActiveTabAlone(t *testing.T) { + a, root := newFeatureTestApp(t) + writeVaultNote(t, root, "gone.md", "---\nview-mode: preview\n---\nbody\n") + writeVaultNote(t, root, "stay.md", "---\nview-mode: preview\n---\nbody\n") + a.idx = a.loadIndexCmd()().(indexLoadedMsg).idx + enableViewModeField(a) + a.openNote("gone.md", true) + a.closeTab(a.activePane, a.activePane.findTab("gone.md")) + a.openNote("stay.md", true) + a.setPaneMode(modeSplit) + if err := os.Remove(filepath.Join(root, "gone.md")); err != nil { + t.Fatal(err) + } + a.reopenClosedTab() + if got := modeOf(t, a, "stay.md"); got != modeSplit { + t.Fatalf("active tab mode %q, want split", got) + } +}