diff --git a/README.md b/README.md index b5a6785..890ece3 100644 --- a/README.md +++ b/README.md @@ -717,6 +717,65 @@ 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. +- 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. + +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 +869,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/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_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/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) + } +} diff --git a/internal/config/prefs.go b/internal/config/prefs.go index cffb461..83f72e9 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "strings" @@ -80,6 +81,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 +122,7 @@ func DefaultPrefs() Prefs { PreviewStyle: "auto", TerminalMouse: true, TerminalMath: "typst", + ViewModeRules: map[string]string{}, RenderTimeout: 10, RenderConcurrency: 2, UnifiedSidebar: true, @@ -236,6 +244,19 @@ 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 = 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) 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..f22d403 100644 --- a/internal/config/prefs_write.go +++ b/internal/config/prefs_write.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + "sort" + "strings" "sync" "github.com/BurntSushi/toml" @@ -33,6 +35,87 @@ func SetValue(section, key string, value any) error { }) } +// SetViewModeRule adds or changes one [terminal.view_mode_rules] entry, +// 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 + 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_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) + } +} 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..b22b9d4 --- /dev/null +++ b/internal/config/viewmode_rules.go @@ -0,0 +1,117 @@ +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; 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 { + return ViewModeRule{}, `no "="` + } + k = normalizeViewModePart(k) + 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: v, Mode: m}, "" +} + +// ValidateViewModeRule is the check writers run before storing a rule. +func ValidateViewModeRule(key, mode string) error { + if _, reason := ParseViewModeRule(key, mode); 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..13cbda3 --- /dev/null +++ b/internal/config/viewmode_rules_test.go @@ -0,0 +1,88 @@ +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) + } +} + +// 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) + } + } +} + +// 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) + } +} 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 f041189..d66c845 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 } @@ -721,7 +726,7 @@ func (a *App) openReference(path string) { if t == nil { return } - t.mode = modePreview + forceTabMode(t, modePreview) if !t.pinned { a.togglePin(p, p.active) } @@ -905,6 +910,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/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/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..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,8 +924,15 @@ func (a *App) openNoteQuiet(path string) { if mode == "" { mode = modeEdit } - pane.tabs = append(pane.tabs, &tab{path: path, mode: mode}) + if justCreated && a.frontmatterModeOn() { + mode = modeEdit + } + t := &tab{path: path, mode: mode} + pane.tabs = append(pane.tabs, t) pane.active = len(pane.tabs) - 1 + if !justCreated { + a.watchFrontmatterMode(t) + } a.touchRecent(path) a.markSessionDirty() } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index fbf071a..441f3f9 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. @@ -109,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 @@ -222,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 { @@ -236,9 +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 + if !justCreated { + a.watchFrontmatterMode(t) + } } if cur := a.activeBuffer(); cur != nil && cur.path != path { a.pushJump(cur.path, cur.ed.Cursor()) @@ -322,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 { + 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. + if !reused { + a.watchFrontmatterMode(t) + } } } 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/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..a424420 --- /dev/null +++ b/internal/tui/settings_viewmode_test.go @@ -0,0 +1,572 @@ +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", "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"}) + if got := paletteIDs(t, a); !slices.Equal(got, []string{"remove"}) { + t.Fatalf("%q palette items = %v", line, got) + } + 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/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/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{} diff --git a/internal/tui/viewmode_frontmatter.go b/internal/tui/viewmode_frontmatter.go new file mode 100644 index 0000000..8848165 --- /dev/null +++ b/internal/tui/viewmode_frontmatter.go @@ -0,0 +1,129 @@ +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 +} + +// 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() +} + +// 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) { + 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_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) + } + }) + } +} 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") + } +} diff --git a/internal/tui/viewmode_open_test.go b/internal/tui/viewmode_open_test.go new file mode 100644 index 0000000..acebed7 --- /dev/null +++ b/internal/tui/viewmode_open_test.go @@ -0,0 +1,739 @@ +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" + "github.com/ZenNotes/tui/internal/vim" +) + +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") + } +} + +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)) + } + }) + } +} + +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) + } +} + +// 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") + } +} + +// 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) + } +} + +// 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) + } +} 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.