Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<key>=<value>" = "<mode>"`, 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
Expand Down Expand Up @@ -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"` |
Expand Down
22 changes: 21 additions & 1 deletion internal/cli/commands_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<key>=<value>" 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} {
Expand All @@ -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
}

Expand Down Expand Up @@ -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 = ""
Expand All @@ -119,6 +127,18 @@ func cmdConfigAction(ctx context.Context, args Args, action string) error {
return errors.New("usage: zn config set <section.key> <value>")
}
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)
Expand Down
29 changes: 29 additions & 0 deletions internal/cli/config_viewmode_case_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
110 changes: 110 additions & 0 deletions internal/cli/config_viewmode_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 21 additions & 0 deletions internal/config/prefs.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"fmt"
"os"
"strings"

Expand Down Expand Up @@ -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 "<key>=<value>" 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
Expand Down Expand Up @@ -115,6 +122,7 @@ func DefaultPrefs() Prefs {
PreviewStyle: "auto",
TerminalMouse: true,
TerminalMath: "typst",
ViewModeRules: map[string]string{},
RenderTimeout: 10,
RenderConcurrency: 2,
UnifiedSidebar: true,
Expand Down Expand Up @@ -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) == "" {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/prefs_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/prefs_values.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
}

Expand Down
Loading
Loading