on master help sign in
remove legacy todo: support, old conflict pairing, dead code and the spec's version history; renumber §10.7-10.10
commit3ba7c84d7b3b95bdd1f66fee8ca4da39275ee9c7 authoriko <ilyakooo0@gmail.com> dateSat 26 Sep 2026, 04:19 (1 month ago) parent65d0c87
15 files changed+98−650
SPEC.md+75−306 crates/fold-core/src/check.rs−22 crates/fold-core/src/edit.rs−14 crates/fold-core/src/merge.rs+3−12 crates/fold-core/src/ops.rs+4−47 crates/fold-core/src/parse.rs+2−32 crates/fold-core/src/tree.rs−12 crates/fold-core/src/vault.rs+1−119 crates/fold-core/tests/edit.rs+1−8 crates/fold-core/tests/merge_check.rs+1−1 crates/fold-core/tests/ops.rs+6−3 crates/fold-core/tests/ordering.rs−39 crates/fold-core/tests/roundtrip.rs−6 crates/fold-core/tests/undo.rs+1−1 crates/fold-tui/src/app.rs+4−28
SPEC.md+75−306
@@ -4,204 +4,6 @@ Status: draft 0.40 · 2026-09-26
44Working name: not chosen yet. This document uses `notes` as the binary name; rename freely.
55Language: Rust · TUI: ratatui · Sync: Syncthing · History: file versioning on one node
66
7−Changes in 0.40: one spelling of task state. A block's state is the checkbox on its own
8−title line, like any node's; `todo:` is gone (read on legacy files, migrated on the next
9−toggle or `check --fix`), so `rg '\[ \]'` is the whole open-task list (§4.5, §8). Block
10−files keep their names when titles change; `check --fix` renames on request (§6.4). A block
11−file's later column-0 lines are adopted by its root instead of making it read-only (§4.9).
12−Verbs track nodes by key — titles plus ordinals among same-titled siblings — never by title
13−path (§3.4). Undo records exactly the files an operation touched and refuses if one changed
14−since (§10.11). Text after a child node is always preceded by a blank line (§4.2), and a
15−heading embed's level is checked against its position (§4.7). §3.1 states what the ordering
16−rule costs.
17−
18−Changes in 0.39: a node's children are an ordered list of **text children** and **child
19−nodes**, interleaved as they are in the file; the body is just the leading text (§3.1, §3.3).
20−Children obey one ordering rule, `(text | item)* section*`, because Markdown cannot close a
21−heading: every verb that places a node keeps it, and `~` moves the node it respells to the
22−boundary between items and sections instead of silently re-parenting its siblings (§3.1,
23−§10.3). Text children are not outline rows and stay where they are when nodes around them
24−move. A section block's embed is a **heading embed**, `## ![[id]]`, so it keeps its place
25−among section siblings (§4.7). Merge compares a node's text children as one field and places
26−insertions by the ordering rule (§12.4).
27−
28−Changes in 0.38: no external editor in 1.0. `E`, the inline-frontmatter spelling, the
29−`inline` render mode and `$EDITOR` are gone; the built-in editor is the only editor (§10.6,
30−§17). Frontmatter never appears as text anywhere.
31−
32−Changes in 0.37: `E` is scoped to the selected node and replaces its subtree wholesale on
33−exit. The text handed to `$EDITOR` carries every nested block's frontmatter **inline**, as a
34−`---` block under its title line, `id` included, so blocks come back matched exactly by id;
35−no line diff, no heuristics (§4.4, §10.6). `e` is unchanged.
36−
37−Changes in 0.36: editing is continuous. The built-in editor is one text over the resolved
38−subtree with no locked lines, no dimming and no commit key: every line carries its owning
39−block invisibly, edits apply to whichever block the cursor is in, and blocks are saved
40−automatically when the cursor leaves them, after a short pause, and on exit (§5.2, §10.6).
41−The *make block* / *cut* distinction is not visible while typing.
42−
43−Changes in 0.35: vocabulary. *Document*, *part* and *cut* were one concept and are now
44−**block**: a node with an id, a file and properties, edited alone. *Edge* is **embed**,
45−which is what `![[id]]` is. The *document pane* is the *reading pane*. Storage sections
46−still say "file" where they mean bytes on disk.
47−
48−Changes in 0.34: every edit changes exactly one file. The editor still shows the whole
49−resolved subtree, but only the lines owned by one **part** — the file the cursor node lives
50−in — are editable; the rest is context, shown but locked, and the status line names the
51−part. Splice is single-file again; the multi-file distributor is gone (§5.2, §10.6).
52−
53−Changes in 0.33: properties never appear in text. The resolved rendering the user reads and
54−edits has no frontmatter and no property blocks; properties are edited in a form (`a`,
55−§10.6) and shown as a dimmed header in the document pane. Splice never touches
56−frontmatter.
57−
58−Changes in 0.32: files are invisible when editing. The user edits a slice of one large
59−document: `e`/`E` open the **resolved** subtree — documents inlined, no `![[id]]`, no ids —
60−with each node's properties shown as a `---` block under its title line (§4.4). Splice
61−parses the result and distributes it back to whichever files own the text, cutting,
62−moving and trashing files as the text requires (§5.2). Files are storage, not UI.
63−
64−Changes in 0.31: one editing model. `e` opens the selected subtree's Markdown —
65−`render(cursor, 1, false)`, frontmatter included — in an editor and splices it back; `E`
66−does the same in `$EDITOR`. Title editing, the block editor and the property editor are
67−gone: a title, a property, a body are all just text in the subtree (§10.6).
68−
69−Changes in 0.30: the `:` command line is a command palette — a fuzzy picker over every
70−action, showing its key, prompting for arguments after selection (§10.9). No command syntax
71−to learn or type.
72−
73−Changes in 0.29: frontmatter is text. No value types, no `tags` key, no `--set`/`--due` on
74−capture, no relative date forms. The app knows five keys — `id`, `todo`, `due`, `done`,
75−`conflict` — reads them as strings, validates the two dates as ISO when it writes them, and
76−preserves every other line verbatim (§3.2, §4.4).
77−
78−Changes in 0.28: the query language, saved views, query blocks, the `v` picker, result
79−views, `notes query` and `--json` are out of 1.0 (§17). `query` fences are reserved. The
80−fresh vault is `# Inbox` alone. The filter box (§10.5) is the only search.
81−
82−Changes in 0.27: no config file. `config.toml` and its seven keys are gone; the vault comes
83−from `$NOTES_VAULT` or the working directory, the editor from `$EDITOR`, and the rest is
84−fixed or a session toggle (`zd`, `zr`). The trash is the only thing outside the vault (§14).
85−
86−Changes in 0.26: the CLI is six commands: `notes`, `capture`, `query`, `check [--fix]`,
87−`merge`, `trash`. `init`, `config`, `view`, `ls`, `set`, `unset`, `cut`, `archive`,
88−`canonicalize` and `adopt` are gone; the TUI does each of them, and `check --fix` absorbs
89−canonicalize (§13). Foreign `.md` files can no longer be adopted; paste their text instead.
90−
91−Changes in 0.25: more removals. The cancelled task state (`[-]`, `todo: cancelled`,
92−`cancelled:`, `X`) and the `priority` and `repeat` keys are gone. No tabs, no `:sort`, no
93−create-from-filter-box, no `notes inbox` / `props` / `id`, no `keys.*` rebinding or theme,
94−no `Ctrl-w`. The `Waiting` default view is gone and the `Inbox` view is now `Queue`.
95−`notes render`, `:export` and the `--plain` lowering are removed: there is no export in 1.0
96−(§17); `render` remains an internal function.
97−
98−Changes in 0.24: seven removals. One edge shape, `![[id]]`, and a document's file starts
99−with the node as spelled — no more `#`-always rule (§4.7, §4.9). No in-session three-way
100−merge: a splice that finds its span changed writes a conflict document like any other
101−conflict (§5.2). No fuzzy title matching in merge (§12.4). Files without a valid `id` are
102−ignored, not "unmanaged" (§4.1). Archive is plain refile under `Archive` (§6.5). No
103−`--as dir/`: the vault is flat and prefixes are unique vault-wide (§6.4). No `conflicts/`
104−directory and no `conflict_with` key: conflict copies are ordinary documents marked
105−`conflict:`, stitched in next to ours (§12.4).
106−
107−Changes in 0.23: no cache and no per-device state. The index is rebuilt from the vault on
108−every start; there are no shadow copies, so sync-conflict merging is two-way (§11.3, §12.3).
109−The in-session splice conflict keeps its three-way base in memory.
110−
111−Changes in 0.22: `uncut` removed. A cut is permanent; a document stays a file until deleted.
112−Conflict resolution copies text instead of uncutting (§12.5).
113−
114−Changes in 0.21: a document's task state is the frontmatter key `todo: open | done |
115−cancelled`, not a checkbox on its title line. Checkboxes remain the syntax for nodes that
116−are not files. Cut and uncut convert between the two (§4.5, §8).
117−
118−Changes in 0.20: the separator between id prefix and name in filenames is `~`:
119−`racfer~order-new-switch.md`. Words inside the prefix stay `-`-joined.
120−
121−Changes in 0.19: filename prefixes are unique on their own, ignoring the title: no two files
122−in a directory share a prefix, whatever their names (§6.4).
123−
124−Changes in 0.18: ids are 64 bits, four `@p` words (`racfer-hattes-dozzod-binwes`). Filenames
125−start with the shortest prefix of the id — one word by default — that is unique in the
126−directory, and grow a word at a time when a collision is found, locally or at merge (§6.4,
127−§12.2). The `-2` suffix is gone.
128−
129−Changes in 0.17: filenames are `<word>-<name>.md`, where `<word>` is the first word of the
130−id (`racfer-order-new-switch.md`). Same-title documents no longer collide across devices
131−except at 1 in 65,536; the merge engine treats an id mismatch as a name collision, not a
132−conflict (§6.4, §12.2).
133−
134−Changes in 0.16: `@key(value)` annotations removed. All metadata is frontmatter again, so
135−only documents carry it: `done:` and `cancelled:` are stamped on task documents, inline
136−tasks record state only, and nothing records creation time. Conflict copies are documents
137−in `conflicts/` again, with `conflict:` frontmatter. `@` appears only in the query language.
138−
139−Changes in 0.15: the "no syntax of our own" restriction is lifted (§1). Consequences:
140−one structural node kind with two spellings, either nestable under either (§3.1);
141−heading levels unbounded, `§5.3` level overflow gone; any node can be a task, headings
142−included (§4.5); a document's task state lives on its own title line, edges carry none
143−(§4.7, §4.9); the app stamps `@created`, `@done`, `@cancelled` as title-line annotations on
144−any node (§3.2); the id moves from the filename into `id:` frontmatter, filenames are plain
145−slugs, edges are `![[id]]` (§3.4, §6.4); `|` is allowed in titles; merge conflicts are
146−inline `@conflict` siblings, `conflicts/` is gone (§12.4). `notes render --plain` lowers
147−the format to standard Markdown for export (§5.1).
148−
149−Changes in 0.14: `[[wiki-links]]` between nodes removed from 1.0 — no link syntax, resolution,
150−rename rewriting, backlinks or tombstones. `[[…]]` in titles and bodies is opaque text,
151−preserved verbatim and reserved (§4.6, §17). Edges (`![[id-name]]`) and external
152−`[text](url)` links are unchanged.
153−
154−Changes in 0.13: capture appends under a `## <today>` child of `Inbox`, created on demand.
155−Fixed behaviour, not a setting; the day title is ISO `YYYY-MM-DD`.
156−
157−Changes in 0.12: every path component the app writes is lowercase — file names, directory
158−names (including `--as`), temp and trash names. Titles keep their case; resolution is
159−case-insensitive.
160−
161−Changes in 0.11: no vault settings at all. Capture always goes to the level-1 section titled
162−`Inbox`, created if missing. `root.md` has no frontmatter in a fresh vault.
163−
164−Changes in 0.10: `done_precision` and `conflicts` removed. `done:` records a date; conflict
165−documents live in `conflicts/`. `inbox` is the only vault setting.
166−
167−Changes in 0.9: `archive` and `:archive-done` removed — done items stay where they are,
168−a display toggle hides them, and `:clear-done` trashes them when a list is finished.
169−(An `archive` section as a plain refile target returns later — see §6.5.)
170−`suggest_bytes` removed — the app never suggests cuts. Three vault settings remain:
171−`inbox`, `done_precision`, `conflicts`.
172−
173−Changes in 0.8: `capture` renamed `inbox` and is a plain link target (no date template);
174−`children: files` removed, so there are no section settings and no derived directories;
175−`cut`, `rename`, `transliterate` and `id_words` removed — cuts are property-triggered or
176−manual, filenames follow titles, names keep Unicode, ids are two words. Five vault settings
177−remain: `inbox`, `archive`, `done_precision`, `conflicts`, `suggest_bytes`.
178−
179−Changes in 0.7: `dir` and `sort` removed. The only section setting is `children: files`;
180−its files go in a directory named after the section. New children are always appended.
181−
182−Changes in 0.6: `config.toml` is gone. Vault settings are `root.md`'s frontmatter; section
183−settings (`children`) are the section's own frontmatter; saved views are query
184−blocks. A fresh vault is initialised with a `root.md` that lists every setting at its default,
185−commented. Only an optional, device-local file for UI preferences remains.
186−
187−Changes in 0.5: the journal is no longer a concept. Capture appends to a configurable,
188−date-templated target (default `Inbox/{date}`), and "children of this section are always
189−files" is a general `[[cut.auto]]` rule. Journal days become ordinary documents; the id-less
190−filename shape and the date resolution step are gone.
191−
192−Changes in 0.4: ids are two-word phonemic names in Urbit's `@p` syllable scheme
193−(`racfer-hattes`, 32 bits) instead of 8-character base32.
194−
195−Changes in 0.3: document files are named `<id>-<name>.md`, where the id is an 8-character
196−random base32 string and the name is a slug of the title. Links and edges resolve by id, so
197−they survive renames and stale rewrites; the name is decoration for humans and other tools.
198−
199−Changes in 0.2: all inline attribute syntax (`@key(value)`) removed. Properties exist only as
200−YAML frontmatter of a file, so a node that needs properties becomes a file ("document").
201−Items can be cut into documents; their checkbox stays on the edge in the parent list.
202−`@id` removed; the file slug is the identity. Merge conflicts are stored as documents in
203−`conflicts/`. Task-state syntax `[ ]`, `[x]`, `[-]` is unchanged.
204−
2057---
2068
2079## 1. Purpose and principles
@@ -356,7 +158,6 @@ Reserved keys (the app assigns semantics; users may still read/write them by han
356158| Key | Value | Meaning |
357159|---|---|---|
358160| `id` | four `@p` words | The block's identity (§3.4). Present on every managed file; never on `root.md`. |
359−| `todo` | `open` \| `done` | **Legacy.** Before 0.40 it held a block's task state. Read only when the block's title line has no checkbox, reported by `notes check`, and removed on the next toggle or `check --fix`, which write the checkbox instead (§4.5). The app never writes it. |
360161| `due` | `YYYY-MM-DD` | Task due date; shown in the outline. |
361162| `done` | `YYYY-MM-DD` | Written when a task block is checked; removed when unchecked. |
362163| `conflict` | `<device> <timestamp>` | Marks a "theirs" copy written by the merge engine (§12.4). |
@@ -542,8 +343,7 @@ Inside the app, frontmatter is storage: the reading pane shows a block's propert
542343dimmed header computed from the index, the property editor (`a`, §10.6) changes them, and
543344the built-in editor's text (§5.1, `resolve_blocks = true`) contains none.
544345
545−Frontmatter never appears as text to the user. (An inline `---`-under-the-title spelling
546−was designed for an external editor and is set aside with it, §17.)
346+Frontmatter never appears as text to the user.
547347
548348### 4.5 Tasks
549349
@@ -569,9 +369,8 @@ There is no cancelled state. A task that will never be done is either checked of
569369deleted.
570370
571371Embeds carry no state: the checkbox travels with the title line into the block's file when
572−it is made (§6.1). A legacy `todo:` key (§3.2) is read as the state when the title line has
573−no checkbox; if both are present the checkbox wins. A task section has its own state *and*
574−the derived count of its subtree. See §8.
372+it is made (§6.1). A task section has its own state *and* the derived count of its subtree.
373+See §8.
575374
576375### 4.6 Links
577376
@@ -801,7 +600,7 @@ Edge cases follow from the tags, not from rules:
801600### 5.3 Levels
802601
803602Heading levels are unbounded (§3.1), so splice never changes a node's spelling and never
804−demotes anything. The level-overflow rule of earlier drafts is gone.
603+demotes anything.
805604
806605### 5.4 One function, three uses
807606
@@ -1012,8 +811,8 @@ Out of scope for 1.0. Nothing is reserved for it.
1012811## 9. Query language
1013812
1014813Not in 1.0. Search is the filter box (§10.5): fuzzy title match plus full-text match over
1015−the whole vault, live, no syntax. A query language with saved views was designed and set
1016−aside (§17, open decision 16); the ```` ```query ```` fence is reserved for it (§4.8).
814+the whole vault, live, no syntax. A query language is future work (§17, §19 decision 16); the
815+```` ```query ```` fence is reserved for it (§4.8).
1017816
1018817---
1019818
@@ -1084,7 +883,7 @@ aside (§17, open decision 16); the ```` ```query ```` fence is reserved for it
1084883| `r` | refile: fuzzy-pick a destination; `Ctrl-Enter` = as first child |
1085884| `c` / `C` | capture to the inbox as bullet / as task |
1086885| `/` | filter box (§10.5) |
1087−| `:` | command palette (§10.9); `?` help |
886+| `:` | command palette (§10.8); `?` help |
1088887| `u` / `U` | undo / redo |
1089888| `q` | quit (nothing is ever unsaved in normal mode) |
1090889
@@ -1128,7 +927,7 @@ app keeps track. Blocks are saved on their own:
1128927The status line shows the title of the block the cursor is in, dimmed, and a dot while
1129928something is unsaved — the only two hints that blocks exist. `Ctrl-c` discards changes made
1130929since the last save. Undo inside the editor is the editor's own; each automatic save is one
1131−entry in the session op log (§10.11). The box is not a Markdown editor and stays one.
930+entry in the session op log (§10.10). The box is not a Markdown editor and stays one.
1132931
1133932Properties are not text. `a` opens the **property editor**: a small form listing the
1134933node's keys and values, where you add, change and delete entries. `due` and `done` accept
@@ -1142,11 +941,7 @@ editing are the same gesture. `x`, `t`, `~`, `>`/`<`, `J`/`K`, `r` and `d` are t
1142941structural verbs; they exist because toggling a checkbox should not require the editor,
1143942and each saves the editor first.
1144943
1145−### 10.7 (removed)
1146−
1147−Result views went with the query language (§9).
1148−
1149−### 10.8 Conflict view
944+### 10.7 Conflict view
1150945
1151946Entered when a `.sync-conflict-*` file is detected or a splice hits a changed span. Shows
1152947each conflict pair (ours in place, the `conflict:` block right after it) side by side; `o` keeps ours,
@@ -1154,7 +949,7 @@ each conflict pair (ours in place, the `conflict:` block right after it) side by
1154949Unresolved pairs remain as `conflict:` blocks and stay listed in the status
1155950line until resolved (§12.5).
1156951
1157−### 10.9 Command palette
952+### 10.8 Command palette
1158953
1159954`:` opens a popup listing every action the TUI has, filtered live by fuzzy match (nucleo)
1160955on the action's name and description, each row showing its key binding if it has one.
@@ -1168,14 +963,14 @@ action reachable by key is in the palette under a readable name (*make block*,
1168963*canonicalize*, *check*, *merge*. `?` is the same popup filtered to show keys, so help and
1169964palette are one thing.
1170965
1171−### 10.10 Markdown styling in the reading pane
966+### 10.9 Markdown styling in the reading pane
1172967
1173968Line-based, not a full renderer: headings by level, task glyphs (`☐ ☑`), resolved blocks'
1174969properties dimmed at the end of the line, underlined external links, styled code fences,
1175970`**bold**` / `*em*` / `` `code` `` inline, blockquote bars. Raw text is never hidden;
1176971`zr` shows exact source, frontmatter included.
1177972
1178−### 10.11 Undo, redo, saving
973+### 10.10 Undo, redo, saving
1179974
1180975Every mutation is an operation with an inverse (span edits, file create/move/delete,
1181976including making blocks). The session op log powers `u` / `U`. Outline verbs write
@@ -1313,7 +1108,7 @@ algorithm is deterministic and idempotent.
13131108
13141109### 12.5 Resolution
13151110
1316−The conflict view (§10.8) lists every `conflict:` block, each shown against the sibling
1111+The conflict view (§10.7) lists every `conflict:` block, each shown against the sibling
13171112before it, and the status line counts them. Resolving a pair:
13181113
13191114- **keep ours** — delete the conflict block and its embed (to trash);
@@ -1355,16 +1150,16 @@ recovery session needs. Everything else is a TUI verb.
13551150
13561151## 14. Settings
13571152
1358−There are no vault settings. Everything that would have been one is either fixed behaviour
1359−or content: the inbox is the section titled `Inbox` (§7);
1153+There are no vault settings. Everything that could be one is either fixed behaviour or
1154+content: the inbox is the section titled `Inbox` (§7);
13601155filenames are an id prefix plus the title; names keep Unicode; ids are four words in
13611156frontmatter (§6.4); making blocks
13621157are property-triggered or manual only; `done:` records a date; conflict copies are
13631158blocks marked `conflict:`. Each was a knob nobody needs to agree on with a file. Two vaults that
13641159contain the same Markdown behave identically.
13651160
1366−There are no device settings either. The app reads no file outside the vault. What used to
1367−be configuration is now:
1161+There are no device settings either. The app reads no file outside the vault. What other
1162+tools keep in configuration is:
13681163
13691164- the vault: `--vault PATH`, else `$NOTES_VAULT`, else the nearest ancestor of `$PWD`
13701165 containing `root.md`, else `~/notes`;
@@ -1516,9 +1311,7 @@ gets wrong. What other tools see:
15161311Encryption; CRDT / operation-log sync; a mobile client (the file format is its contract);
15171312a query language, saved views and `--json` output (the ```` ```query ```` fence is reserved); attachment management beyond ignoring `assets/`; full Markdown
15181313rendering or WYSIWYG; plugins; task recurrence; spaced repetition; whiteboards; multiple
1519−vaults open at once; general transclusion; an external `$EDITOR` handoff (designed:
1520−resolved subtree with frontmatter inline under each block's title, ids included, replaced
1521−wholesale on exit); properties on non-blocks; export or lowering
1314+vaults open at once; general transclusion; an external `$EDITOR` handoff; properties on non-blocks; export or lowering
15221315to standard Markdown (the four non-standard forms of §4.2 are the whole gap); a cancelled
15231316task state; wiki-links between nodes and backlinks
15241317(`[[…]]` is reserved, §4.6); rendering the format correctly in third-party editors.
@@ -1539,7 +1332,7 @@ task state; wiki-links between nodes and backlinks
15391332
15401333---
15411334
1542−## 19. Open decisions
1335+## 19. Decisions
15431336
154413371. **Name.** Binary `notes` is a placeholder.
154513382. **Dated inbox, ISO titles, fixed.** Capture creates `## YYYY-MM-DD` under `Inbox`. There
@@ -1548,85 +1341,61 @@ task state; wiki-links between nodes and backlinks
154813413. **Filenames are named once.** A title change does not rename the file: renames sync as
15491342 delete-plus-create and race with edits on other devices. The id prefix identifies the
15501343 file; the name is a hint that `notes check --fix` refreshes on request.
1551−3b. **Id format.** Four-word `@p` names (64 bits). Two words (32 bits) were enough for
1552− identity but left the one-word filename prefix with only 16 bits of room to grow; four
1553− words make the id collision-free in practice and give the prefix three words of slack.
1554− Chosen for being pronounceable, typeable from memory, and self-validating against the
1555− syllable tables; nobody is expected to type all four.
1556−4. **Task sections.** Any node can be a task, so a project heading can be checked off. The
1344+4. **Id format.** Four-word `@p` names (64 bits): collision-free in practice, with three words
1345+ of slack for the filename prefix to grow into. Pronounceable, typeable from memory, and
1346+ self-validating against the syllable tables; nobody is expected to type all four.
1347+5. **Task sections.** Any node can be a task, so a project heading can be checked off. The
15571348 derived count is still shown beside it; there is no roll-up in either direction (§8.3).
1558−4b. **One spelling of task state.** The checkbox, for every node. Until 0.39 blocks used
1559− `todo:` in frontmatter instead; that hid block tasks from `rg '\[ \]'` and cost a
1560− conversion every time a task became a block. All-frontmatter was never an option (it
1561− would make every task a file). A block keeps one thing a plain task lacks: `done:`.
1562−5. **Two task states.** Open and done. Cancelled was removed: it was a third value in every
1563− table, a second date key, and a key binding, for a distinction between "done" and
1564− "won't do" that a deleted line or a done line expresses well enough. `[-]` is read as
1565− done for old vaults.
1566−6. **No un-block.** Earlier drafts had an inverse of making a block that dropped the frontmatter. Removed:
1567− a verb whose defining feature is discarding data is a footgun, and the manual route
1568− (yank, paste, delete) makes the loss explicit. Revisit only if vaults fill with
1569− blocks nobody wanted.
1570−7. **No export.** `notes render` and the `--plain` lowering were removed for 1.0. The
1571− resolved render is now only what the reading pane reads; a standalone-Markdown exporter
1572− is a later feature with a known scope (§4.2's four forms).
1573−8. **No fuzzy matching in merge.** A node retitled on one device and edited on another
1574− comes out of a merge as two nodes. The fuzzy title match that would have paired them
1575− needed a tunable threshold and could pair the wrong nodes; a duplicate is cheaper to
1576− fix than a wrong pairing, so the step was removed.
1577−8b. **Two-way merge, no cache.** Shadow copies would have made sync merges three-way and
1578− resolved most differences automatically; they were the only per-device state and were
1579− removed with it (§11.3). Sync conflicts now produce a conflict pair for every differing
1580− field. The mitigation is structural: keep concurrently-edited things in separate files
1581− (§12.1), so conflicts are small and rare. If they turn out to be neither, a per-device
1582− base is the thing to bring back.
1583−9. **Text is edited as text, properties as a form.** Earlier drafts had a title editor and
1584− a block editor beside an external-editor handoff; those collapsed into one subtree edit,
1585− and the external handoff was then dropped from 1.0 (§17). Properties briefly appeared in
1586− the text as `---` blocks and were taken out again: frontmatter is a storage format, and
1587− showing it would surface files. The property editor is the one non-text edit. The
1588− built-in editor is a text box; if it wants to become a Markdown editor, stop.
1589−10. **Inbox by title.** Capture finds the inbox by the title `Inbox`. Renaming it means the
1590− next capture creates a new one. A hidden marker property was the alternative and would
1591− have been the last setting.
1592−10b. **No config file.** Seven presentation keys, each already available from the environment
1593− or replaceable by a key binding, were not worth a file format, a lookup order, and a
1594− `notes config` command. If a real per-device need appears (a colour-blind palette, say)
1595− it can come back as one environment variable.
1596−11. **Windows.** Slugs and atomic writes are Windows-safe by design; nothing else is tested.
1597−12. **No links in 1.0.** Storing links by id, displaying the live title, and rewriting labels
1598− on rename were all designed and then cut for the first implementation. Embeds already
1599− prove the id machinery; links can return on top of it without changing the file format,
1600− which is why `[[…]]` is reserved rather than free.
1601−13. **No per-node timestamps.** Title-line annotations (`@done(…)`, `@created(…)`) were
1602− tried in 0.15 and removed: one metadata mechanism is worth more than inline completion
1603− dates. An inline task that needs a date becomes a block; the day section is the
1604− only creation stamp anything else gets.
1605−13b. **Flat vault.** `--as dir/` was removed; the app never creates a directory and ignores
1606− any it finds. Hierarchy is embeds, and one place for files keeps prefixes unique
1607− vault-wide with no per-directory rule.
1608−14. **Prefix collisions.** Two blocks whose ids share a first word are `racfer~notes.md`
1349+6. **One spelling of task state.** The checkbox, for every node, so `rg '\[ \]'` finds every
1350+ open task and making a block never converts anything. Frontmatter holds only what a
1351+ checkbox cannot: `done:`.
1352+7. **Two task states.** Open and done. "Won't do" is a deleted line or a done line; a third
1353+ state would cost a value in every table, a second date key and a key binding. `[-]` is
1354+ read as done.
1355+8. **No un-block.** There is no inverse of making a block: it would have to discard the
1356+ frontmatter, and a verb whose defining feature is discarding data is a footgun. The
1357+ manual route (yank, paste, delete) makes the loss explicit.
1358+9. **No export.** The resolved render is what the reading pane shows; a standalone-Markdown
1359+ exporter is future work with a known scope (§4.2's four forms).
1360+10. **No fuzzy matching in merge.** A node retitled on one device and edited on another
1361+ comes out of a merge as two nodes. A fuzzy title match would need a tunable threshold and
1362+ could pair the wrong nodes; a duplicate is cheaper to fix than a wrong pairing.
1363+11. **Two-way merge, no cache.** A per-device base copy would make sync merges three-way,
1364+ but it would be the only per-device state (§11.3). Sync conflicts therefore produce a
1365+ conflict pair for every differing field; the mitigation is structural — keep
1366+ concurrently-edited things in separate files (§12.1), so conflicts are small and rare.
1367+12. **Text is edited as text, properties as a form.** One subtree edit in a text box covers
1368+ titles, bodies and structure. Frontmatter is a storage format, and showing it would
1369+ surface files, so the property editor is the one non-text edit. The built-in editor is a
1370+ text box; if it wants to become a Markdown editor, stop.
1371+13. **Inbox by title.** Capture finds the inbox by the title `Inbox`. Renaming it means the
1372+ next capture creates a new one. A hidden marker property would be the only setting.
1373+14. **No config file.** Presentation choices are fixed or session toggles; a config format, a
1374+ lookup order and a `notes config` command are not worth it. A real per-device need (a
1375+ colour-blind palette, say) would be one environment variable.
1376+15. **Windows.** Slugs and atomic writes are Windows-safe by design; nothing else is tested.
1377+16. **No links or query language in 1.0.** Embeds prove the id machinery, and links can be
1378+ built on it without changing the file format, which is why `[[…]]` is reserved rather
1379+ than free. The likely query shape is "everything is a key" (`due:..today`, text is
1380+ literal, `-` negates, `..` ranges); until then the filter box is the only search and the
1381+ `query` fence is reserved.
1382+17. **No per-node timestamps.** One metadata mechanism is worth more than inline annotations
1383+ such as `@done(…)`. An inline task that needs a date becomes a block; the day section is
1384+ the only creation stamp anything else gets.
1385+18. **Flat vault.** The app never creates a directory and ignores any it finds. Hierarchy is
1386+ embeds, and one place for files keeps prefixes unique vault-wide with no per-directory
1387+ rule.
1388+19. **Prefix collisions.** Two blocks whose ids share a first word are `racfer~notes.md`
16091389 and `racfer-wolsun~order-new-switch.md`, regardless of their titles; the prefix grows only
1610− as far as needed and never shrinks (§6.4). Uniqueness is judged on the prefix alone, not
1611− the whole filename, so the prefix is a complete address and titles can change freely.
1612− The cost is that roughly one file in 256 needs a second word from day one; that was
1613− judged cheaper than a rule that depends on titles. The full id was rejected as too long
1614− for a name, the bare slug as too collision-prone across devices, and a numeric `-2` as
1615− meaningless.
1616−17. **Ownership travels with lines.** Three other models were tried: distributing a
1617− multi-file edit back by re-matching nodes (needs heuristics and a prompt when they
1618− fail), showing nested blocks as placeholders (leaks files into the text), and locking
1619− every block but one (a seam the user has to work around). Tagging each buffer line with
1620− its block and saving blocks as the cursor leaves them needs no matching, shows no seam,
1621− and still writes each file from its own text. An external editor cannot carry tags;
1622− the design for one (frontmatter inline, ids included, subtree replaced on exit) is
1623− parked in §17.
1624−16. **No query language in 1.0.** Several syntaxes were sketched — boolean with `@key`, a
1625− search box with `is:` and `key:value`, and "everything is a key" (`todo:open
1626− due:..today`, text is literal, `-` negates, `..` ranges). The last is the likely
1627− shape when it returns. Until then the filter box is the only search and the `query`
1628− fence is reserved.
1629−15. **Third-party editors are readers, not editors.** Once the format owns syntax, Obsidian
1630− and friends stop being a safe place to restructure a vault. Editing a body paragraph
1631− there is still fine; moving headings is not. This is the price of §1 principle 2 and is
1632− accepted.
1390+ as far as needed and never shrinks (§6.4). Uniqueness is judged on the prefix alone, so
1391+ the prefix is a complete address and titles can change freely. Roughly one file in 256
1392+ needs a second word from day one, which is cheaper than any rule that depends on titles;
1393+ the full id is too long for a name, the bare slug too collision-prone across devices, and
1394+ a numeric `-2` meaningless.
1395+20. **Ownership travels with lines.** Each editor line is tagged with its block, and blocks
1396+ are saved as the cursor leaves them. That needs no re-matching of nodes after an edit,
1397+ shows no seam between files, and still writes each file from its own text. An external
1398+ editor cannot carry the tags, so there is none in 1.0 (§17).
1399+21. **Third-party editors are readers, not editors.** Once the format owns syntax, Obsidian
1400+ and friends are not a safe place to restructure a vault. Editing a paragraph there is
1401+ fine; moving headings is not. This is the price of §1 principle 2 and is accepted.
crates/fold-core/src/check.rs−22
@@ -141,15 +141,6 @@ pub fn check(vault: &Vault) -> Vec<Diagnostic> {
141141 message: "filename is not <prefix>~<name>.md".into(),
142142 }),
143143 }
144− // task key sanity
145− if let Some(todo) = b.prop("todo") {
146− if todo != "open" && todo != "done" {
147− out.push(Diagnostic {
148− file: b.path.clone(),
149− message: format!("todo: must be open or done, got {:?}", todo),
150− });
151− }
152− }
153144 for k in ["due", "done"] {
154145 if let Some(v) = b.prop(k) {
155146 if !is_iso_date(v) {
@@ -310,19 +301,6 @@ pub fn fix(vault: &mut Vault) -> std::io::Result<usize> {
310301 count += 1;
311302 }
312303 }
313− // legacy `todo:` keys: the canonical render above wrote the state as the
314− // title line's checkbox, so the key goes (§4.5)
315− for i in 0..vault.tree.files.len() {
316− let f = &vault.tree.files[i];
317− let migrated = f.nodes.iter().any(|n| {
318− n.block.as_ref().and_then(|b| b.prop("todo")).is_some()
319− && crate::parse::title_has_checkbox(n.title_span.text(&f.text))
320− });
321− if migrated {
322− crate::ops::set_frontmatter_key(vault, i, "todo", None)?;
323− count += 1;
324− }
325− }
326304 // embeds in the form of their block's spelling (§4.7), one at a time:
327305 // each respell may move an embed and re-parse its file
328306 for _ in 0..vault.tree.blocks.len() {
crates/fold-core/src/edit.rs−14
@@ -413,17 +413,3 @@ pub fn open_editor(vault: &Vault, root: NRef) -> EditBuffer {
413413 EditBuffer::build(vault, root)
414414}
415415
416−/// Renames detected by the splice: if a block's title line changed, its file
417−/// is renamed (§5.2 edge case). Returns the new title if the first owned
418−/// line's title differs from the stored one.
419−pub fn title_of_first_line(line: &str) -> Option<String> {
420− let t = line.trim_start();
421− if t.starts_with('#') {
422− let h = t.chars().take_while(|&c| c == '#').count();
423− Some(t[h..].trim().to_string())
424− } else if let Some(rest) = t.strip_prefix("- ") {
425− Some(rest.trim().to_string())
426− } else {
427− None
428− }
429−}
crates/fold-core/src/merge.rs+3−12
@@ -99,7 +99,6 @@ fn standalone_tree(text: &str) -> Tree {
9999 props: fm.as_ref().map(|f| f.props.clone()).unwrap_or_default(),
100100 frontmatter_raw: fm.as_ref().map(|f| f.raw.clone()).unwrap_or_default(),
101101 frontmatter_span: fm.as_ref().map(|f| f.span),
102− edge_span: None,
103102 };
104103 let pf = parse_file("m.md", text, 0, Some(block));
105104 Tree {
@@ -682,7 +681,7 @@ fn parse_conflict_name(name: &str) -> (String, String) {
682681 (device, stamp)
683682}
684683
685−/// Find unresolved conflict blocks: blocks with a `conflict:` key (§10.8).
684+/// Find unresolved conflict blocks: blocks with a `conflict:` key (§10.7).
686685/// Returns (ours, theirs) pairs; theirs is the block right after ours.
687686pub fn conflict_pairs(vault: &Vault) -> Vec<(NRef, NRef)> {
688687 conflict_pairs_tree(&vault.tree)
@@ -701,16 +700,8 @@ pub fn conflict_pairs_tree(tree: &Tree) -> Vec<(NRef, NRef)> {
701700 let p = nd.parent.map(|pp| (fi, pp)).unwrap();
702701 let sibs = tree.raw_children(p);
703702 if let Some(pos) = sibs.iter().position(|&s| s == (fi, ni)) {
704− // a section's conflict is its first child
705− // (see merge_pair); anything else follows ours
706− let ours = if pos > 0 {
707− Some(sibs[pos - 1])
708− } else if tree.node(p).kind == Kind::Section {
709− Some(p)
710− } else {
711− None
712− };
713− if let Some(ours) = ours {
703+ // the conflict block is ours' next sibling (§12.4)
704+ if let Some(ours) = pos.checked_sub(1).map(|i| sibs[i]) {
714705 let ours = {
715706 let rc = tree.resolved_child(ours);
716707 if rc != ours { rc } else { ours }
crates/fold-core/src/ops.rs+4−47
@@ -1,6 +1,6 @@
11//! Structural operations on the tree (§5.2, §6, §7, §8). Every op mutates
22//! files through the vault; the undo log records what each one changed as
3−//! an `Inverse` (§10.11).
3+//! an `Inverse` (§10.10).
44
55use crate::ident::{slug, Id};
66use crate::parse::{Kind, Span, TaskState};
@@ -38,7 +38,7 @@ pub struct Change {
3838 pub after: Option<String>,
3939}
4040
41−/// One entry of the session op log (§10.11): exactly the files an operation
41+/// One entry of the session op log (§10.10): exactly the files an operation
4242/// changed, created or deleted, before and after.
4343#[derive(Debug, Clone)]
4444pub struct Inverse {
@@ -78,7 +78,7 @@ impl Inverse {
7878
7979 /// Undo: put every touched file back as it was before. Refuses, writing
8080 /// nothing, if any of them is no longer as the operation left it — an
81− /// external change since then (§10.11).
81+ /// external change since then (§10.10).
8282 pub fn undo(&self, vault: &mut Vault) -> std::io::Result<()> {
8383 self.swap(vault, false)
8484 }
@@ -211,7 +211,6 @@ fn find_or_create_day(vault: &mut Vault, inbox: NRef) -> std::io::Result<NRef> {
211211 Err(io_err("day section not created"))
212212}
213213
214−
215214fn append_top_section(vault: &mut Vault, title: &str) -> std::io::Result<NRef> {
216215 let root_file = 0;
217216 let old = vault.tree.files[root_file].text.clone();
@@ -383,7 +382,7 @@ pub fn toggle_taskness(vault: &mut Vault, r: NRef) -> std::io::Result<()> {
383382
384383/// Write a node's task state (§4.5, §8.1): the checkbox on its title line
385384/// (`None` removes it). For a block, `done:` is stamped when it is checked
386−/// and removed otherwise, and a legacy `todo:` key is dropped.
385+/// and removed otherwise.
387386pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io::Result<()> {
388387 let n = vault.tree.node(r);
389388 let is_block = n.is_block();
@@ -417,9 +416,6 @@ pub fn set_task(vault: &mut Vault, r: NRef, state: Option<TaskState>) -> std::io
417416 _ => None,
418417 };
419418 set_frontmatter_key(vault, file, "done", done.as_deref())?;
420− if vault.tree.files[file].nodes.iter().any(|n| n.block.as_ref().and_then(|b| b.prop("todo")).is_some()) {
421− set_frontmatter_key(vault, file, "todo", None)?;
422− }
423419 }
424420 Ok(())
425421}
@@ -706,7 +702,6 @@ pub fn shift_document(text: &str, parent_level: usize, indent: usize) -> String
706702 props: Default::default(),
707703 frontmatter_raw: String::new(),
708704 frontmatter_span: None,
709− edge_span: None,
710705 };
711706 let pf = crate::parse::parse_file("clip.md", text, 0, Some(block));
712707 let tree = crate::tree::Tree {
@@ -1280,7 +1275,6 @@ fn reposition(
12801275 place(vault, Some(r), parent, pos, &new).map(|_| true)
12811276}
12821277
1283−
12841278/// A node's text respelled: its title line as a section at `level` or as an
12851279/// item, and everything under it shifted to match — an item's content sits
12861280/// two columns in, a section's at its own indent, and nested headings stay
@@ -1352,43 +1346,6 @@ pub fn promote(vault: &mut Vault, r: NRef) -> std::io::Result<bool> {
13521346 place(vault, Some(m), grand, pos + 1, &shifted)
13531347}
13541348
1355−/// Rename a node's title (used by the editor when it detects a title change,
1356−/// and by tests).
1357−pub fn rename_title(vault: &mut Vault, r: NRef, new_title: &str) -> std::io::Result<()> {
1358− let n = vault.tree.node(r);
1359− let file = r.0;
1360− let ts = n.title_span;
1361− let line = ts.text(&vault.tree.files[file].text).to_string();
1362− // replace only the title text after the marker/checkbox
1363− let marker_len = line.len() - line.trim_start().len();
1364− let after_indent = &line[marker_len..];
1365− let prefix_len = if after_indent.starts_with('#') {
1366− let h = after_indent.chars().take_while(|&c| c == '#').count();
1367− marker_len + h + 1
1368− } else if after_indent.starts_with("- ") {
1369− marker_len + 2
1370− } else {
1371− marker_len
1372− };
1373− let mut prefix = line[..prefix_len].to_string();
1374− // keep checkbox
1375− let rest = &line[prefix_len..];
1376− let checkbox = if rest.starts_with("[ ] ") {
1377− "[ ] "
1378− } else if rest.starts_with("[x] ") || rest.starts_with("[X] ") || rest.starts_with("[-] ") {
1379− "[x] "
1380− } else {
1381− ""
1382− };
1383− if !checkbox.is_empty() {
1384− prefix.push_str(checkbox);
1385− }
1386− // the file keeps its name: names are set once, and `check --fix`
1387− // brings them up to date on request (§6.4)
1388− let new_line = format!("{}{}", prefix, new_title);
1389− vault.write_span(file, ts, &new_line)
1390−}
1391−
13921349/// Append a plain item child titled `title` under `parent`; returns the new
13931350/// node. Used by `N` in the TUI (§10.3).
13941351pub fn append_child_public(
crates/fold-core/src/parse.rs+2−32
@@ -41,8 +41,6 @@ pub struct Block {
4141 /// Byte span of the whole frontmatter (fences plus one trailing blank
4242 /// line), if present.
4343 pub frontmatter_span: Option<Span>,
44− /// Byte span of the embed line in the parent file (None for root.md).
45− pub edge_span: Option<Span>,
4644}
4745
4846impl Block {
@@ -63,7 +61,7 @@ pub enum Content {
6361pub struct Node {
6462 pub kind: Kind,
6563 pub title: String,
66− /// From the checkbox on the title line, or `todo:` for a block.
64+ /// From the checkbox on the title line (§4.5).
6765 pub task: Option<TaskState>,
6866 /// Byte span of the title line (no trailing newline).
6967 pub title_span: Span,
@@ -693,37 +691,14 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
693691 diagnostics,
694692 };
695693
696− if let Some(mut b) = block {
694+ if let Some(b) = block {
697695 if is_block_file {
698696 // The block root is the first top-level node.
699697 if let Some(&first) = pf.nodes[root_idx].children.first() {
700− // Task state is the checkbox on the block's own title line,
701− // as for any node (§4.5). A legacy `todo:` key is read only
702− // when there is no checkbox, and is non-canonical either way.
703− if b.prop("todo").is_some() {
704− pf.nodes[first]
705− .noncanonical
706− .push("todo: key (the state belongs on the title line)".into());
707− }
708− if pf.nodes[first].task.is_none() {
709− pf.nodes[first].task = match b.prop("todo") {
710− Some("open") => Some(TaskState::Open),
711− Some("done") => Some(TaskState::Done),
712− _ => None,
713− };
714− }
715− if matches!(b.prop("todo"), Some(v) if v != "open" && v != "done") {
716− let d = Diag {
717− span: b.frontmatter_span.unwrap_or_default(),
718− message: "todo: value must be open or done".into(),
719− };
720− pf.diagnostics.push(d);
721− }
722698 pf.nodes[first].block = Some(b);
723699 }
724700 } else {
725701 // root.md: attach to the implicit Root.
726− b.edge_span = None;
727702 pf.nodes[root_idx].block = Some(b);
728703 pf.nodes[root_idx].task = None;
729704 }
@@ -731,11 +706,6 @@ pub fn parse_file(path: &str, text: &str, file_idx: usize, block: Option<Block>)
731706 pf
732707}
733708
734−/// Whether a title line carries a checkbox right after its marker (§4.3).
735−pub fn title_has_checkbox(line: &str) -> bool {
736− classify_title(line).map(|(_, info)| info.task.is_some()).unwrap_or(false)
737−}
738−
739709/// Starts of the text children that follow a child node with no blank line
740710/// between them (§4.2): where canonical form inserts one.
741711pub fn unseparated_text(n: &Node, text: &str) -> Vec<usize> {
crates/fold-core/src/tree.rs−12
@@ -117,18 +117,6 @@ impl Tree {
117117 segs
118118 }
119119
120− /// The block that owns a node: nearest ancestor-or-self that is one (§5.2).
121− pub fn owning_block(&self, r: NRef) -> NRef {
122− let mut cur = Some(r);
123− while let Some(c) = cur {
124− if self.node(c).is_block() {
125− return c;
126− }
127− cur = self.node(c).parent.map(|p| (c.0, p));
128− }
129− self.root
130− }
131−
132120 /// Resolve an embed node to the block it references (one level).
133121 pub fn resolved_child(&self, r: NRef) -> NRef {
134122 let n = self.node(r);
crates/fold-core/src/vault.rs+1−119
@@ -1,17 +1,14 @@
11//! The vault on disk: loading, indexing, atomic span-preserving writes,
22//! trash (§4.1, §11).
33
4−use crate::ident::{filename, slug, split_filename, Id};
4+use crate::ident::{split_filename, Id};
55use crate::parse::{parse_file, parse_frontmatter, Block, Kind, ParsedFile, Span};
66use crate::tree::{NRef, Tree};
7−use std::collections::HashMap;
87use std::path::{Path, PathBuf};
98
109pub struct Vault {
1110 pub dir: PathBuf,
1211 pub tree: Tree,
13− /// blake3 hash of each file's text as last read or written by us (§5.2.5).
14− pub hashes: Vec<String>,
1512}
1613
1714/// A file found in the vault directory that the parser ignores (§4.1).
@@ -44,7 +41,6 @@ impl Vault {
4441 root: (0, 0),
4542 blocks: Vec::new(),
4643 },
47− hashes: Vec::new(),
4844 };
4945 v.reload()?;
5046 Ok(v)
@@ -53,7 +49,6 @@ impl Vault {
5349 /// Rebuild the index from the vault (§11.3).
5450 pub fn reload(&mut self) -> std::io::Result<()> {
5551 let mut files: Vec<ParsedFile> = Vec::new();
56− let mut hashes = Vec::new();
5752 let root_text = read_if_exists(&self.dir.join("root.md"))?.unwrap_or_default();
5853 let root_block = Block {
5954 id: None,
@@ -65,9 +60,7 @@ impl Vault {
6560 .map(|f| f.raw)
6661 .unwrap_or_default(),
6762 frontmatter_span: parse_frontmatter(&root_text).map(|f| f.span),
68− edge_span: None,
6963 };
70− hashes.push(blake3::hash(root_text.as_bytes()).to_hex().to_string());
7164 files.push(parse_file("root.md", &root_text, 0, Some(root_block)));
7265
7366 let mut entries: Vec<String> = std::fs::read_dir(&self.dir)?
@@ -96,14 +89,12 @@ impl Vault {
9689 None => continue, // ignored: never parsed (§4.1)
9790 };
9891 let idx = files.len();
99− hashes.push(blake3::hash(text.as_bytes()).to_hex().to_string());
10092 let block = Block {
10193 id: Some(id),
10294 path: name.clone(),
10395 props: fm.as_ref().map(|f| f.props.clone()).unwrap_or_default(),
10496 frontmatter_raw: fm.as_ref().map(|f| f.raw.clone()).unwrap_or_default(),
10597 frontmatter_span: fm.as_ref().map(|f| f.span),
106− edge_span: None,
10798 };
10899 files.push(parse_file(&name, &text, idx, Some(block)));
109100 }
@@ -122,39 +113,12 @@ impl Vault {
122113 }
123114 }
124115 }
125− // Set each block's edge_span by finding its embed in some file.
126− for f in files.iter_mut() {
127− for n in f.nodes.iter_mut() {
128− n.block.as_mut().map(|b| b.edge_span = None);
129− }
130− }
131− for ((fi, ni), id) in &blocks {
132− let mut found: Option<Span> = None;
133− let mut count = 0;
134− for f in files.iter() {
135− for n in &f.nodes {
136− if n.embed.as_ref() == Some(id) {
137− count += 1;
138− if found.is_none() {
139− found = Some(n.title_span);
140− }
141− }
142− }
143− }
144− if count > 1 {
145− // duplicate embed: diagnostic (§6.2); recorded in check
146− }
147− if let Some(span) = found {
148− files[*fi].nodes[*ni].block.as_mut().unwrap().edge_span = Some(span);
149− }
150− }
151116
152117 self.tree = Tree {
153118 files,
154119 root: (0, 0),
155120 blocks,
156121 };
157− self.hashes = hashes;
158122 Ok(())
159123 }
160124
@@ -203,11 +167,6 @@ impl Vault {
203167 self.tree.files.iter().position(|f| f.path == path)
204168 }
205169
206− /// The file a node lives in, and its recorded hash.
207− pub fn hash_of(&self, file: usize) -> &str {
208− &self.hashes[file]
209− }
210−
211170 /// Replace a byte span in a file atomically (§11.1), then re-parse.
212171 pub fn write_span(&mut self, file: usize, span: Span, replacement: &str) -> std::io::Result<()> {
213172 let f = &self.tree.files[file];
@@ -242,7 +201,6 @@ impl Vault {
242201 props: parse_frontmatter(text).map(|f| f.props).unwrap_or_default(),
243202 frontmatter_raw: parse_frontmatter(text).map(|f| f.raw).unwrap_or_default(),
244203 frontmatter_span: parse_frontmatter(text).map(|f| f.span),
245− edge_span: None,
246204 })
247205 } else {
248206 old_block.map(|mut b| {
@@ -260,7 +218,6 @@ impl Vault {
260218 })
261219 };
262220 self.tree.files[file] = parse_file(&path, text, file, block);
263− self.hashes[file] = blake3::hash(text.as_bytes()).to_hex().to_string();
264221 self.restitch();
265222 Ok(())
266223 }
@@ -280,50 +237,9 @@ impl Vault {
280237 }
281238 }
282239 }
283− for f in self.tree.files.iter_mut() {
284− for n in f.nodes.iter_mut() {
285− if let Some(b) = n.block.as_mut() {
286− b.edge_span = None;
287− }
288− }
289− }
290− for ((fi, ni), id) in &blocks {
291− let mut edge: Option<Span> = None;
292− for f in self.tree.files.iter() {
293− for n in &f.nodes {
294− if n.embed.as_ref() == Some(id) {
295− edge = Some(n.title_span);
296− }
297− }
298− }
299− if let Some(span) = edge {
300− self.tree.files[*fi].nodes[*ni]
301− .block
302− .as_mut()
303− .unwrap()
304− .edge_span = Some(span);
305− }
306− }
307240 self.tree.blocks = blocks;
308241 }
309242
310− /// Create a new block file (§6.1): picks a unique prefix, writes
311− /// `render(node, 1, false)` with frontmatter, returns the file index.
312− pub fn create_block_file(
313− &mut self,
314− id: &Id,
315− title: &str,
316− body: &str,
317− ) -> std::io::Result<usize> {
318− let prefix = self.unique_prefix(id);
319− let name = slug(title);
320− let fname = filename(&prefix, &name);
321− let text = format!("---\nid: {}\n---\n\n{}", id, body);
322− atomic_write(&self.dir.join(&fname), &text)?;
323− self.reload()?;
324− Ok(self.file_index(&fname).unwrap())
325− }
326−
327243 /// Shortest prefix of `id` not already used by another file (§6.4).
328244 pub fn unique_prefix(&self, id: &Id) -> String {
329245 self.unique_prefix_except(id, None)
@@ -347,27 +263,6 @@ impl Vault {
347263 .collect()
348264 }
349265
350− /// Rename a block's file after a title change (§6.4).
351− pub fn rename_block_file(&mut self, file: usize, new_title: &str) -> std::io::Result<()> {
352− let old_path = self.tree.files[file].path.clone();
353− let node = &self.tree.files[file];
354− let root_node = node.nodes[node.root_node].children[0];
355− let id = match &node.nodes[root_node].block {
356− Some(b) => b.id.clone().unwrap(),
357− None => return Ok(()),
358− };
359− let prefix = split_filename(&old_path)
360− .map(|(p, _)| p.to_string())
361− .unwrap_or_else(|| self.unique_prefix_except(&id, Some(&old_path)));
362− let new_name = filename(&prefix, &slug(new_title));
363− if new_name == old_path {
364− return Ok(());
365− }
366− std::fs::rename(self.dir.join(&old_path), self.dir.join(&new_name))?;
367− self.reload()?;
368− Ok(())
369− }
370−
371266 /// Move a file to the trash (§11.5) and remove it from the vault.
372267 pub fn trash_file(&mut self, file: usize) -> std::io::Result<()> {
373268 let path = self.tree.files[file].path.clone();
@@ -618,12 +513,6 @@ impl Vault {
618513 }
619514 }
620515
621− /// Collect every node in the resolved tree (for the outline and filter).
622− pub fn all_nodes(&self) -> Vec<NRef> {
623− let mut out = Vec::new();
624− self.tree.walk(self.tree.root, &mut |_, r| out.push(r));
625− out
626− }
627516}
628517
629518/// Case-insensitive title comparison, Unicode-aware (§3.4).
@@ -631,10 +520,3 @@ pub fn title_eq(a: &str, b: &str) -> bool {
631520 a == b || a.to_lowercase() == b.to_lowercase()
632521}
633522
634−/// Map from id string to NRef, for embed resolution in the TUI.
635−pub fn id_map(tree: &Tree) -> HashMap<String, NRef> {
636− tree.blocks
637− .iter()
638− .map(|(r, id)| (id.as_str().to_string(), *r))
639− .collect()
640−}
crates/fold-core/tests/edit.rs+1−8
@@ -1,4 +1,4 @@
1−use fold_core::edit::{open_editor, title_of_first_line, Owner};
1+use fold_core::edit::{open_editor, Owner};
22use fold_core::ops;
33use fold_core::vault::Vault;
44
@@ -121,13 +121,6 @@ fn deleting_nested_block_title_trashes_block() {
121121 assert_eq!(block_owner, Owner { file: 1, block_ord: 1 });
122122}
123123
124−#[test]
125−fn title_detection() {
126− assert_eq!(title_of_first_line("# Hello"), Some("Hello".into()));
127− assert_eq!(title_of_first_line("- [ ] Task"), Some("[ ] Task".into()));
128− assert_eq!(title_of_first_line("plain"), None);
129−}
130−
131124// ---------------------------------------------------------------- regressions
132125
133126/// Open the editor on `r`, mark every owner dirty and save: nothing on disk
crates/fold-core/tests/merge_check.rs+1−1
@@ -147,7 +147,7 @@ fn check_reports_bad_dates() {
147147 std::fs::write(dir.path().join("root.md"), "# A\n").unwrap();
148148 std::fs::write(
149149 dir.path().join("racfer~task.md"),
150− format!("---\nid: {}\ntodo: open\ndue: next friday\n---\n\n- task\n", id),
150+ format!("---\nid: {}\ndue: next friday\n---\n\n- [ ] task\n", id),
151151 )
152152 .unwrap();
153153 let v = Vault::open(dir.path()).unwrap();
crates/fold-core/tests/ops.rs+6−3
@@ -62,7 +62,6 @@ fn make_block_writes_file_and_embed() {
6262 assert!(bf.text.starts_with(&format!("---\nid: {}\n---\n\n", id)), "{}", bf.text);
6363 assert!(bf.text.contains("- [ ] Order new switch"), "{}", bf.text);
6464 assert!(bf.text.contains("Two options."), "{}", bf.text);
65− assert!(!bf.text.contains("todo:"));
6665 // filename: <prefix>~<name>.md
6766 assert!(bf.path.ends_with("~order-new-switch.md"), "{}", bf.path);
6867}
@@ -79,7 +78,6 @@ fn task_block_toggle_stamps_done() {
7978 let bf = &v.tree.files[1];
8079 assert!(bf.text.contains("- [x] Order new switch"), "{}", bf.text);
8180 assert!(bf.text.contains("done: 20"), "{}", bf.text);
82− assert!(!bf.text.contains("todo:"), "{}", bf.text);
8381 ops::toggle_task(&mut v, block).unwrap();
8482 let bf = &v.tree.files[1];
8583 assert!(bf.text.contains("- [ ] Order new switch"), "{}", bf.text);
@@ -232,7 +230,12 @@ fn rename_block_title_keeps_the_filename_until_fix() {
232230 let embed = v.tree.resolved_children(v.tree.resolved_children(v.tree.root)[0])[0];
233231 let block = v.tree.resolved_child(embed);
234232 let old_path = v.tree.files[1].path.clone();
235− ops::rename_title(&mut v, block, "renamed title").unwrap();
233+ let _ = block;
234+ // retitle it the way any editor would: by changing the title line
235+ let path = v.dir.join(&old_path);
236+ let text = std::fs::read_to_string(&path).unwrap().replace("- task", "- renamed title");
237+ std::fs::write(&path, text).unwrap();
238+ v.reload().unwrap();
236239 assert_eq!(v.tree.files.len(), 2);
237240 // names are set once (§6.4): the file keeps its name ...
238241 assert_eq!(v.tree.files[1].path, old_path);
crates/fold-core/tests/ordering.rs−39
@@ -514,42 +514,3 @@ fn placement_as_asked_is_not_reported_as_moved() {
514514 .collect();
515515 assert_eq!(kids, ["a", "new", "b"]);
516516}
517−
518−// ------------------------------------------------------------- task state
519−
520−#[test]
521−fn legacy_todo_key_is_read_then_migrated_on_toggle() {
522− let dir = tempfile::tempdir().unwrap();
523− std::fs::write(dir.path().join("root.md"), "![[racfer-hattes-mislup-nodrys]]\n").unwrap();
524− std::fs::write(
525− dir.path().join("racfer~t.md"),
526− "---\nid: racfer-hattes-mislup-nodrys\ntodo: open\ndue: 2026-09-20\n---\n\n- t\n",
527− )
528− .unwrap();
529− let mut v = Vault::open(dir.path()).unwrap();
530− let t = at(&v, "t");
531− assert_eq!(v.tree.node(t).task, Some(fold_core::TaskState::Open));
532− assert!(check::check(&v).iter().any(|d| d.message.contains("todo: key")));
533− ops::toggle_task(&mut v, t).unwrap();
534− let text = &v.tree.files[t.0].text;
535− assert!(text.contains("- [x] t") && !text.contains("todo:") && text.contains("due: 2026-09-20"), "{}", text);
536−}
537−
538−#[test]
539−fn check_fix_migrates_legacy_todo_keys() {
540− let dir = tempfile::tempdir().unwrap();
541− std::fs::write(dir.path().join("root.md"), "### ![[racfer-hattes-mislup-nodrys]]\n").unwrap();
542− std::fs::write(
543− dir.path().join("racfer~t.md"),
544− "---\nid: racfer-hattes-mislup-nodrys\ntodo: done\ndone: 2026-09-01\n---\n\n# t\n",
545− )
546− .unwrap();
547− let mut v = Vault::open(dir.path()).unwrap();
548− check::fix(&mut v).unwrap();
549− let t = at(&v, "t");
550− assert_eq!(
551− v.tree.files[t.0].text,
552− "---\nid: racfer-hattes-mislup-nodrys\ndone: 2026-09-01\n---\n\n# [x] t\n"
553− );
554− assert_eq!(v.tree.node(t).task, Some(fold_core::TaskState::Done));
555−}
crates/fold-core/tests/roundtrip.rs−6
@@ -10,7 +10,6 @@ fn tree_of(text: &str) -> Tree {
1010 props: Default::default(),
1111 frontmatter_raw: String::new(),
1212 frontmatter_span: None,
13− edge_span: None,
1413 };
1514 let pf = parse_file("root.md", text, 0, Some(block));
1615 assert!(pf.diagnostics.is_empty(), "diags: {:?}", pf.diagnostics);
@@ -175,7 +174,6 @@ fn block_file_tree() -> Tree {
175174 props: Default::default(),
176175 frontmatter_raw: String::new(),
177176 frontmatter_span: None,
178− edge_span: None,
179177 };
180178 let root = parse_file("root.md", root_text, 0, Some(root_block));
181179
@@ -188,7 +186,6 @@ fn block_file_tree() -> Tree {
188186 props: fm.props,
189187 frontmatter_raw: fm.raw,
190188 frontmatter_span: Some(fm.span),
191− edge_span: None,
192189 };
193190 let bf = parse_file("dozzod~zfs-layout.md", block_text, 1, Some(block));
194191 assert!(bf.diagnostics.is_empty(), "diags: {:?}", bf.diagnostics);
@@ -246,7 +243,6 @@ fn bullet_root_block_file() {
246243 props: fm.props,
247244 frontmatter_raw: fm.raw,
248245 frontmatter_span: Some(fm.span),
249− edge_span: None,
250246 };
251247 let pf = parse_file("racfer~order-new-switch.md", text, 0, Some(block));
252248 assert!(pf.diagnostics.is_empty(), "diags: {:?}", pf.diagnostics);
@@ -273,7 +269,6 @@ fn tree_with_block(root_text: &str, block_text: &str, block_path: &str) -> Tree
273269 props: Default::default(),
274270 frontmatter_raw: String::new(),
275271 frontmatter_span: None,
276− edge_span: None,
277272 };
278273 let root = parse_file("root.md", root_text, 0, Some(root_block));
279274 let fm = fold_core::parse::parse_frontmatter(block_text).unwrap();
@@ -284,7 +279,6 @@ fn tree_with_block(root_text: &str, block_text: &str, block_path: &str) -> Tree
284279 props: fm.props,
285280 frontmatter_raw: fm.raw,
286281 frontmatter_span: Some(fm.span),
287− edge_span: None,
288282 };
289283 let bf = parse_file(block_path, block_text, 1, Some(block));
290284 Tree {
crates/fold-core/tests/undo.rs+1−1
@@ -1,4 +1,4 @@
1−//! The session op log (§10.11): an entry holds exactly the files an
1+//! The session op log (§10.10): an entry holds exactly the files an
22//! operation touched; undo and redo refuse when one changed since.
33
44use fold_core::ops::{self, Inverse, Snapshot};
crates/fold-tui/src/app.rs+4−28
@@ -77,7 +77,6 @@ pub struct App {
7777 props_target: Option<NRef>,
7878 props_rows: Vec<(String, String, bool)>,
7979 props_sel: usize,
80− props_editing: Option<String>,
8180 // text prompt (refile destination, capture text)
8281 prompt: Option<Prompt>,
8382 // reading pane (§10.4)
@@ -85,7 +84,7 @@ pub struct App {
8584 read_search: String,
8685 read_matches: Vec<usize>,
8786 read_match_idx: usize,
88− // conflict view (§10.8)
87+ // conflict view (§10.7)
8988 conflict_idx: usize,
9089 // watcher (§11.2)
9190 watcher: Option<notify::RecommendedWatcher>,
@@ -159,7 +158,6 @@ impl App {
159158 props_target: None,
160159 props_rows: Vec::new(),
161160 props_sel: 0,
162− props_editing: None,
163161 prompt: None,
164162 read_cursor: 0,
165163 read_search: String::new(),
@@ -189,7 +187,6 @@ impl App {
189187 })
190188 }
191189
192− pub fn pane_outline_pub(&self) -> Rect { self.pane_outline }
193190 pub fn toolbar_labels(&self) -> Vec<String> {
194191 self.toolbar.iter().map(|(_, a)| a.to_string()).collect()
195192 }
@@ -223,7 +220,6 @@ impl App {
223220 pub fn props_row_pos(&self, i: usize) -> Option<(u16, u16)> {
224221 self.props_row_rects.get(i).map(|r| (r.x, r.y))
225222 }
226− pub fn pane_reading_pub(&self) -> Rect { self.pane_reading }
227223
228224 /// Turn mouse support on (called by run(); off in tests).
229225 pub fn enable_mouse(&mut self) {
@@ -304,21 +300,8 @@ impl App {
304300 due
305301 }
306302
307− pub fn poll_watcher_debug(&mut self) -> String {
308− let mut n = 0;
309− let mut msgs = Vec::new();
310− if let Some(rx) = &self.watch_rx {
311− while let Ok(res) = rx.try_recv() {
312− n += 1;
313− msgs.push(format!("{:?}", res.map(|e| e.kind)));
314− }
315− }
316− let poll = self.poll_watcher();
317− format!("{} events: {:?} poll={} self_write={:?}", n, msgs, poll, self.self_write_until.elapsed())
318− }
319−
320303 /// Reload after an external change (§11.2): editor saves first, then
321− /// re-parse, cursor re-attached by id, path, nearest ancestor. A new
304+ /// re-parse, cursor re-attached by id, key, deepest surviving step. A new
322305 /// sync-conflict file starts the merge flow (§12).
323306 pub fn reload_external(&mut self) {
324307 if self.edit_buf.is_some() {
@@ -852,17 +835,10 @@ impl App {
852835 Vec::new()
853836 };
854837 self.props_sel = 0;
855− self.props_editing = None;
856838 self.mode = Mode::Props;
857839 }
858840
859841 pub fn key_props(&mut self, key: KeyEvent) {
860− if self.props_editing.is_some() {
861− // handled by the prompt path
862− self.mode = Mode::Normal;
863− self.props_editing = None;
864− return;
865− }
866842 match key.code {
867843 KeyCode::Esc => {
868844 self.mode = Mode::Normal;
@@ -1765,7 +1741,7 @@ impl App {
17651741 }
17661742
17671743 /// Turn the pending snapshot into an op-log entry holding exactly the
1768− /// files the verb changed (§10.11); a verb that changed nothing leaves
1744+ /// files the verb changed (§10.10); a verb that changed nothing leaves
17691745 /// no entry and keeps the redo stack.
17701746 fn settle_undo(&mut self) {
17711747 if let Some(snap) = self.pending_undo.take() {
@@ -2849,7 +2825,7 @@ fn style_markdown_line(l: &str, vault: &Vault, _ctx: NRef) -> Line<'static> {
28492825 Line::from(TSpan::raw(l.to_string()))
28502826}
28512827
2852−/// The help text, shared by `?` in the TUI and `fold help` (§10.9).
2828+/// The help text, shared by `?` in the TUI and `fold help` (§10.8).
28532829pub fn help_text() -> Vec<Line<'static>> {
28542830 let entries: &[(&str, &str)] = &[
28552831 ("", "fold — a tree of notes and tasks in plain Markdown."),