on master help sign in
fold/SPEC.md
1683 lines 96 kB history raw
1# SPEC — a tree-shaped plain-text notes and task manager for the terminal
3Status: draft 0.40 · 2026-09-26
4Name: fold. The binary is `fold`; the crates are `fold-*` (§15.1).
5Language: Rust · TUI: ratatui · Sync: Syncthing · History: file versioning on one node
7---
9## 1. Purpose and principles
11A mouse-first TUI — every action is a click, a drag or the wheel away, and every one also
12has a key — for a single body of notes stored as Markdown. There is one organizing
13primitive — the **subtree** — and one organizing verb — **refile** (move a subtree under a
14different parent). Everything else (inbox, tasks, projects, index) is a view over the
15same tree.
17Principles, in priority order:
191. **The files are the database.** Every fact the app knows is recoverable from the Markdown.
20 There are no caches, no configuration, and no per-device state beyond the trash and the
21 remembered view (§10.1). The app never writes a byte it didn't need to change.
222. **Markdown-shaped, with our own syntax where it pays.** Headings, bullets, checkboxes,
23 fences and YAML frontmatter are used as they are. On top of them the format owns a small,
24 closed set of things a standard renderer will not understand: `![[id]]` embeds, headings
25 deeper than six, checkboxes on headings, headings nested under bullets. The set is listed
26 in §4.2 and is small enough to lower to standard Markdown later (§17).
273. **Zoom is the unit of reading, editing and splitting.** A single function,
28 `render(node, base)`, produces the document you read, the text you edit, and the file
29 a block lives in.
303b. **The user reads and edits one document.** The vault is stored as many files, but nothing
31 the user reads or types shows a file boundary, embed or id. Every line knows which block
32 owns it; the editor routes each change to that block's file, and saves blocks on its own
33 as the cursor moves and the typing pauses. Files are how the app keeps its promises, not
34 something the user operates (§5.2, §10.6).
354. **Safe beside other editors and beside a phone.** Atomic writes, external-change
36 detection, and structural conflict merging are core features, not add-ons.
375. **Legible without the app.** Plain `rg`, Helix and a phone editor must produce something
38 sensible from the same files. Standard-Markdown export is a non-goal for 1.0 (§17).
40---
42## 2. Terminology
44| Term | Meaning |
45|---|---|
46| Vault | A directory containing `root.md` and, optionally, block files. |
47| Tree | The single logical outline formed by `root.md` plus all block files stitched in. |
48| Node | One thing in the outline: a title line and its children. Spelled as a section or an item. |
49| Children | A node's ordered content: text children and child nodes, interleaved (§3.1). |
50| Text child | A run of lines among a node's children that is not a node: prose, code, a quote (§3.3). |
51| Section | A node spelled as an ATX heading `#`, `##`, … (no upper bound). |
52| Item | A node spelled as a `- ` bullet. |
53| Spelling | Whether a node is written as a heading or a bullet. Presentation only (§3.1). |
54| Task | A node with a checkbox after its marker — block or not. |
55| Block | A node with its own identity and file. Only blocks have properties; every edit changes exactly one block. |
56| Id | A random four-word phonemic name (`racfer-hattes-dozzod-binwes`) in a block's `id:` frontmatter; its stable identity. |
57| Name | The slug of a block's title. With a prefix of the id in front, its filename. Decoration, not identity. |
58| Property | A key in a block's YAML frontmatter. |
59| Body | A node's leading text children: what comes before its first child node. |
60| Zoom | Viewing a node as a standalone block via `render(node, base)`. |
61| Splice | Writing an edited zoomed block back into its source span. |
62| Make a block | Giving a node an id and its own file, leaving an embed in the parent. |
63| Embed | `![[id]]` alone on a line (or as a heading, `## ![[id]]`) at the node's position in the parent. |
64| Span | A byte range in a file. Every node knows its span. |
65| Path | A node's address by ancestor titles: `Homelab/NAS/ZFS layout`. |
67---
69## 3. Data model
71### 3.1 Nodes
73There is one structural kind of node, with two **spellings**:
75```
76Root — implicit; children are the top-level nodes of root.md; body is any preamble text
77Section — spelled as a heading; may hold prose; children of either spelling
78Item — spelled as a bullet; may hold note lines; children of either spelling
79```
81A node's **children** are an ordered list in which two things interleave: **child nodes**
82and **text children** (§3.3), in the order the file has them:
84```
85Trip ← section
86├─ text "Plan below."
87├─ item book flights
88├─ item book hotel
89└─ text "Budget is tight, so check both before paying."
90```
92Rules:
94- Any node may be the child of any node. A heading under a bullet is a section whose parent
95 is an item; a bullet under a heading is an item whose parent is a section.
96- **Ordering rule.** Every node's children match
98 ```
99 children := (text | item)* section*
100 ```
102 Markdown has no way to close a heading: anything written after a section child — text or
103 an item at that position — belongs to that section. So once a section child appears, only
104 section children follow it. The parser produces nothing else; every verb that places a
105 node (§6.5, §10.3, §12.4) keeps the rule by **clamping**: an item goes no later than just
106 before its new parent's first section child, and a section no earlier than just after its
107 last item child. The *boundary* of a node's children is the position between its last
108 item or text child and its first section child.
110 **What the rule costs.** A node cannot have items or text after a section child: under
111 `# Trip` with a `## Budget` subsection, there is no way to put another bullet or paragraph
112 back under Trip after Budget. Everything written there is Budget's. Plain Markdown cannot
113 express it either, so the format accepts the limit rather than invent a closing syntax
114 no other tool would read. The practical consequences are that `>`, `<`, paste, refile and
115 capture sometimes place an item earlier than asked (before the first section), `J` / `K`
116 refuse to move an item past a section, and `~` moves the node it respells. The TUI says
117 so in the status line whenever the rule, not the user, chose the position; none of these
118 is a bug. The workaround is the obvious one:
119 put the later items in a section of their own (`## Also`), or keep sections last.
120- **Text children are content, not outline.** They are not rows in the outline pane, not
121 targets, never blocks or tasks, and are never moved on their own. A verb that moves,
122 deletes or refiles a node moves exactly that node's own lines; the text children around it
123 stay where they are, with the siblings they were between. They move only with the node
124 that contains them.
125- Heading level and indent are **derived from position**, never stored:
126 `level(n) = 1 + number of section ancestors of n`, and `indent(n) = 2 × number of item
127 ancestors of n`. A section under an item is written at that item's child indent with its
128 own level. There is no upper bound on levels.
129- Spelling is presentation: `~` toggles it (§10.3) and nothing else about the node changes.
130 Both spellings can be tasks, hold text, be refiled anywhere, and be made into blocks. The
131 one consequence of spelling is position: under the ordering rule a node respelled by `~`
132 moves to its parent's boundary, so its siblings keep their parent.
133- Sibling order is block order and is meaningful.
134- Any node may be a **block** (the root of its own file). Its file starts with the node
135 as it is spelled — a heading or a bullet — and that is where its spelling is defined
136 (§4.9). The embed repeats it in its form (§4.7) so the parent file parses into the right
137 shape on its own; the app keeps the two in step (`~` rewrites both), and where they
138 disagree the file wins and `fold check` reports the embed.
140### 3.2 Properties
142Only blocks have properties. They are the lines of the file's YAML frontmatter. The app
143reads frontmatter to find its own five keys and otherwise treats it as text: every line it
144did not write is preserved byte for byte, in place, whatever it contains — lists, nested
145maps, comments, other tools' keys. There is no typing: a value is the string after the
146colon, and only the app's two date keys are checked to be ISO dates, and only when the app
147writes them.
149- Keys: `[A-Za-z_][A-Za-z0-9_-]*`, case-sensitive.
150- Key order is preserved as written; the app writes `id` first and appends new keys at the end.
152Giving a property to a node that is not a block **makes it one** (§6.1). This is the only way
153a property can come into existence, and it is automatic and permanent: there is no uncut.
154There is no other per-node metadata: a node that is not a block has a title, a checkbox,
155a body and children, and nothing else.
157Reserved keys (the app assigns semantics; users may still read/write them by hand):
159| Key | Value | Meaning |
160|---|---|---|
161| `id` | four `@p` words | The block's identity (§3.4). Present on every managed file; never on `root.md`. |
162| `due` | `YYYY-MM-DD` | Task due date; shown in the outline. |
163| `done` | `YYYY-MM-DD` | Written when a task block is checked; removed when unchecked. |
164| `conflict` | `<device> <timestamp>` | Marks a "theirs" copy written by the merge engine (§12.4). |
166There are no setting keys. Nothing in any frontmatter changes how the app behaves.
168New children are always appended after the last existing child; sibling order is manual and
169nothing reorders it on insert.
171Everything else (`tags`, `priority`, `est`, `waiting`, `since`, `author`, `source`, …) is
172user-defined, and the app never looks at it.
174### 3.3 Text children
176A text child is a run of consecutive raw lines, opaque to the tree, between two structural
177points: the title line, a child node, or the end of the node. The parser only needs to know
178enough to find the *next title line*: it tracks fenced code blocks (```` ``` ```` and `~~~`,
179any indent) so that `#` or `- ` inside a fence never starts a node. Paragraphs, quotes,
180tables, images, HTML and code are all text.
182A text line belongs to the deepest open node whose region it reaches:
184- an item's region is lines indented at least `indent + 2`;
185- a section's region is lines at or beyond its own indent;
186- the root's region is everything.
188So a line at column 0 after `- b` under `# Trip` is Trip's text, not b's: it ends the list.
189(CommonMark would read an unindented line right after a list item with no blank line between
190as a lazy continuation of that item; this format does not. Canonical form therefore always
191writes a blank line there (§4.2), and then both agree.)
193A blank line belongs to the text run it is in, or — between nodes — to the deepest open node
194before it: the blank line after `- a` is `a`'s trailing separator. Blank lines are therefore
195never lost or invented when a node is rendered or moved.
197The **body** of a node is its leading text: the text children before its first child node.
198It is what the reading pane shows under the title before any child, and what "a node with a
199body" means elsewhere in this spec.
201### 3.4 Identity and addressing
203A block's identity is its **id**: a four-word phonemic name such as
204`racfer-hattes-dozzod-binwes`, 64 random bits encoded with Urbit's `@p` syllable tables
205(§6.4), generated from OS randomness
206when the block is created and stored as the `id` key of its frontmatter. Its filename is
207`<prefix>~<name>.md`: the shortest leading run of the id's words that no other file in the
208directory uses as its prefix — usually one — then the slug of its title
209(`racfer~order-new-switch.md`). The prefix alone identifies the file; the name is for humans. The filename is decoration that any tool may change and the
210app will repair (§6.4). The one exception is `root.md`, which has no
211id. A non-block node has no stored identity. Two things stand in for one, and they are
212different on purpose:
214- **Typed targets** (below) name a node by titles. Titles are what a person can type, and a
215 title that matches twice is an ambiguity error, never a guess.
216- **Tracking** — re-attaching the cursor, folds and a verb's result across a re-parse —
217 uses the node's **key**: the block whose file holds it (none for `root.md`), then one step
218 per level down from that file's root, each step the title *and the node's ordinal among
219 same-titled siblings*. Two siblings with one title, or two empty new nodes, have
220 different keys. Keys are never shown and never typed.
222Verbs never find a node again by its title path: a verb that re-parses finds its result by
223key, by id, or by position.
225Embeds reference the id alone (`![[racfer-hattes-dozzod-binwes]]`), so nothing in the vault
226depends on a filename and a rename touches only the renamed file.
228Resolution order for a command target (the *refile*, *go to* and *make block* prompts, `--to`, …):
2301. **Id** — the target is a valid id, or a leading run of its words that matches exactly
231 one block (`racfer`, `racfer-hattes`, …), with or without `![[ ]]`.
2322. **Path** — segments separated by `/`, matched case-insensitively against titles from the
233 root (`Homelab/NAS`). A leading `./` resolves relative to the current zoom root.
2343. **Title** — a bare name matching exactly one node's title. Dated inbox days
235 (`2026-09-10`) resolve this way; there is nothing special about them.
237Ambiguous or missing targets are an error for the command that named them; broken or
238duplicate embeds are diagnostics in `fold check` (§15.7).
240Renaming a block's title does not rename its file (§6.4); a stale name is harmless, since
241nothing references the filename.
243Titles may not contain `/` (the path separator). `[` and `]` are discouraged.
245### 3.5 Derived facts
247Computed by the index, never stored in files: heading level, indent, path, file, span,
248open/done task counts per subtree (`3/7` in the outline; a conflict copy's tasks, §12.4,
249are not counted above it), per-file mtime. Per-node timestamps are not tracked by the app
250(§12.6).
252---
254## 4. File format
256### 4.1 Vault layout
258```
259vault/
260 root.md # the tree; with no blocks this is the whole database
261 <prefix>~<name>.md # block files (0..n), e.g. racfer~order-new-switch.md
262 assets/ # anything non-Markdown; ignored by the parser
263```
265The vault is flat: every block sits beside `root.md`. Only `root.md` and `.md` files
266whose frontmatter carries a valid `id` (§6.4) are parsed. Everything else — subdirectories,
267non-Markdown files, `.md` files without an id — is ignored: never parsed, never written,
268never deleted. There is no import: to bring a foreign `.md` file in, paste its text into
269a node (or `fold capture < file.md`) and let the app assign ids as it makes blocks. `fold check`
270lists ignored `.md` files so nothing is forgotten in the vault.
272There is no configuration and no configuration file. The inbox is the section titled
273`Inbox` (§7). The app owns no file in the vault besides the
274Markdown it is asked to write.
276A fresh vault is `root.md` alone, and stays so until a node acquires a property or you make
277it a block by hand.
279### 4.1.1 A fresh vault
281The TUI, when pointed at an empty directory, writes this `root.md`:
283````markdown
284# Inbox
286Captures land here under a heading for the day. Press `s` on this section to give it its own small file for phone capture.
287````
289`Inbox` is an ordinary section: rename it, move it, delete it. Deleting it only means the
290next capture recreates it.
292### 4.2 Canonical subset
294The app **reads** any CommonMark it can make sense of and **writes** this subset:
296- UTF-8, `\n` line endings, file ends with exactly one `\n`, no trailing whitespace.
297- Frontmatter, if present, is the first thing in the file: `---`, YAML, `---`, blank line.
298 `id` is the first key. The app writes its own keys as `key: value` on one line each and
299 leaves every other line as it found it.
300- ATX headings only, `#`+ then a space, at the node's indent. No upper bound on level.
301 Setext headings are converted on write.
302- Bullets are `- ` only. `*` and `+` are accepted on read.
303- Nesting is 2 spaces per item ancestor (§3.1). Tabs and 4-space indents are accepted on read.
304- Checkboxes are exactly `[ ]` and `[x]`, between the marker and the title, on headings
305 or bullets alike. `[X]` is accepted on read; `[-]` is read as `[x]`.
306- Exactly one blank line between a node's body and its first child, and between sibling
307 sections. Loose/tight lists are preserved as found.
308- A blank line before any text child that follows a child node (§3.3), so that CommonMark
309 readers agree the text is not part of that node.
311Canonicalization is **lazy**: a node is rewritten in canonical form only when it is touched
312(edited, spliced, moved). `fold check --fix` (or *canonicalize* in the palette) rewrites
313everything at once.
315**What is ours.** A standard Markdown renderer will mishandle exactly these four things:
316`![[id]]` embeds (bare or as a heading), heading levels beyond six, checkboxes on headings,
317and headings indented under bullets. Nothing else in a vault is non-standard.
319### 4.3 Title lines
321```
322section := indent "#"+ " " (checkbox " ")? title
323item := indent "- " (checkbox " ")? title
324checkbox := "[" ("_" | "x") "]" -- "_" denotes a space
325indent := (" ")*
326title := any text not starting with whitespace; may not contain "/"
327```
329There is no attribute syntax. An `@word` or `key:: value` in a title or body is text, and
330so is `[[…]]` (reserved, §4.6).
331A title line with an empty title is invalid; `fold check` reports it, the TUI won't create it.
333### 4.4 Frontmatter
335```
336frontmatter := "---" NL yaml "---" NL
337```
339Recognised only at byte 0 of a file. It belongs to the file's block (§4.9). The app scans
340it for top-level `key: value` lines, keeps the raw text, and rewrites only the lines for
341keys it changes, so comments and any structure it does not understand survive untouched. A
342`---` anywhere else in a file is a thematic break and is body text.
344Inside the app, frontmatter is storage: the reading pane shows a block's properties as a
345dimmed header computed from the index, the property editor (`a`, §10.6) changes them, and
346the built-in editor's text (§5.1, `resolve_blocks = true`) contains none.
348Frontmatter never appears as text to the user.
350### 4.5 Tasks
352Any node can be a task, and there is one way to write it: a **checkbox after the marker on
353its title line**. `- [ ]` and `## [ ]` are both open tasks; `[x]` is done. A block is no
354different — its checkbox is on its own title line, in its file:
356```markdown
357---
358id: racfer-hattes-mislup-nodrys
359due: 2026-09-20
360---
362- [ ] Order new switch
363```
365What a block adds is a completion record: checking it writes `done: <date>` to its
366frontmatter, and unchecking removes the date. A plain task has no frontmatter and so no
367date; that is the whole difference. Because the state is always on the title line,
368`rg '\[ \]'` over the vault is the complete open-task list.
370There is no cancelled state. A task that will never be done is either checked off or
371deleted.
373Embeds carry no state: the checkbox travels with the title line into the block's file when
374it is made (§6.1). A task section has its own state *and* the derived count of its subtree.
375See §8.
377### 4.6 Links
379There are no links between nodes in 1.0. `[[target]]` and `[[target|label]]` are opaque
380text wherever they appear: not parsed, not resolved, not rewritten on rename, not reported.
381The syntax is **reserved** for a later version; write it by hand if another tool needs it.
383External links are ordinary Markdown `[text](url)` and are opened with `xdg-open` / `open`.
385### 4.7 Embeds
387An embed is where a block is stitched into the tree. It has two forms, one per position in
388the ordering rule (§3.1):
390- **bare**, `![[id]]` alone on a line at the node's indent — an item position;
391- **heading**, `## ![[id]]` — a heading line holding nothing but the embed, at the level and
392 indent a section has at that position.
394```
395![[dozzod-binwes-talsun-worbec]]
396 ![[racfer-hattes-mislup-nodrys]] -- a child of the item above it
397## ![[lacnum-walbyn-dirlyn-havtyp]] -- a section-position embed
398```
400The app writes the form that matches the block's spelling: a block whose root is a section
401is embedded with a heading embed, a block whose root is an item with a bare one. The
402heading form exists because a bare embed after a section sibling would, like any line
403there, belong to that section (§3.1). Either form is read anywhere; an embed whose form
404does not match its block's spelling is a diagnostic (§15.7) that `fold check --fix`
405rewrites, and it is placed where its form puts it.
407The embed names the block by id and nothing else; title, checkbox, properties, text and
408children all live in the file. The embed's form repeats the block's spelling so that the
409parent file parses into the right shape without opening the block. An embed line has no
410children in the parent file; lines indented under a bare embed, or nested under a heading
411embed, are a diagnostic.
413A heading embed's **level** is read like any heading's: honoured as written, and it
414decides the embed's parent the same way. The level its position gives is `1 +` its section
415ancestors (§3.1), and that is the level the app writes. A written level deeper than that —
416left by another editor, or by moving text around by hand — still parses under the same
417parent (a shallower one would parse elsewhere and is simply a different position), so
418`fold check` reports it and `check --fix` rewrites it to the positional level; the
419structure does not change.
421`![[` anywhere else is body text. The app does not implement general transclusion.
423### 4.8 Query blocks (reserved)
425A fenced block with info string `query` is body text in 1.0. The info string is reserved
426for a later query language (§17); leave such blocks alone and they will start working.
428### 4.9 Block files
430A block's file is exactly `render(node, 1, resolve_blocks = false)`:
432```
433[frontmatter, always beginning with id; done: if the block is a checked task]
434[the node's title line, as spelled: "# Title" or "- Title"]
435[its children: text and nodes, in order]
436```
438- The root is written as it is spelled in the tree. A block that is a heading is a `#` at column 0; one
439 that is a bullet is a `- ` at column 0, with its body and children indented under it exactly
440 as they would be in the parent. The file is the only place the spelling is recorded.
441- The block's task state is the checkbox on its title line, with a `done:` date in the
442 frontmatter when it is checked. Toggling the node — from the outline, or the embed —
443 writes to the file.
444- The first node at column 0 is the block's root. **Anything after it at column 0 — more
445 nodes, or text — is adopted as the root's children**, in order, as if it were indented
446 under it. That is what a phone editor produces by appending `- new thing` to a block
447 spelled as a bullet, and it must never make the block unusable. Adopted lines are
448 non-canonical (`fold check` notes them); the next write of the block, or `check --fix`,
449 writes them in place under the root (indented under a bullet root, re-levelled under a
450 heading root).
451- Text before the root other than frontmatter, or an embed before it, is a diagnostic; the
452 app treats that file as read-only until fixed, since it cannot tell what the block is.
453- `root.md` is the file of the implicit Root node; its frontmatter holds vault-level
454 properties (rarely needed) and never an `id`.
456### 4.10 Example
458`root.md`:
460```markdown
461# Homelab
463Two boxes in the closet, one at Hetzner.
465## NAS
467### ![[dozzod-binwes-talsun-worbec]]
469## Networking
471- [ ] Replace the flaky switch
472![[racfer-hattes-mislup-nodrys]]
474# Inbox
476## 2026-09-10
478- Talked to Anya about the venue.
479 ### Options
480 Warehouse on Ligovsky, or the old bakery. Both need a licence.
481- [x] Send the deposit
482```
484`dozzod~zfs-layout.md`:
486```markdown
487---
488id: dozzod-binwes-talsun-worbec
489since: 2024-03
490tags: [storage, homelab] # user-defined; the app preserves it and ignores it
491---
493# ZFS layout
495Mirrored pairs, no raidz. Snapshots hourly via sanoid.
497## [ ] Snapshot policy
499- hourly, keep 24
500- [x] Move scratch to its own dataset
501```
503`racfer~order-new-switch.md`:
505```markdown
506---
507id: racfer-hattes-mislup-nodrys
508due: 2026-09-20
509---
511- [ ] Order new switch
512 Two options, noted under Networking.
513```
515Note that "Replace the flaky switch" is a one-line task with no file; "ZFS layout" is a
516section block, so its embed is a heading at the level it has under NAS, while "Order new
517switch" is an item block and is embedded bare. "Order new switch" became a block the moment
518it got a due date; its checkbox moved into the file with its title line, and its file starts
519with a bullet because that is what it is. "Options" is
520a section nested under an item, and "Snapshot policy" is a section that is itself a task.
522---
524## 5. Projection: zoom, render, splice
526### 5.1 `render(node, base, resolve_blocks) → String`
528Emits the node and its subtree, in document order, as a standalone Markdown document:
5301. If the node is a block and `base == 1` and `resolve_blocks = false`: its frontmatter.
5312. The node's title line — checkbox included — at level `base` for a section, or at
532 indent 0 for an item.
5333. The node's children in order: each text child verbatim (dedented by the node's original
534 indent), each child node recursively — sections re-levelled by `base − level(node)`,
535 everything re-indented by `−indent(node)`.
5364. Blank lines are text and are reproduced where they are (§3.3); `render` adds none.
5375. Embeds: with `resolve_blocks = false`, written as embeds in their form (§4.7). With `resolve_blocks = true`, the
538 block's subtree is inlined at the embed's position, its frontmatter omitted, its own
539 embeds resolved in turn. The resolved text contains no embed, no id, no frontmatter and no
540 file boundary: it is titles, checkboxes, bodies and nesting, nothing else.
542Properties:
544- Levels are unbounded, so `render` is total.
545- For a block, `render(node, 1, false)` is byte-identical to its file.
546- For a top-level node of `root.md` that is not a block, `render(node, 1, false)` is
547 byte-identical to its span.
548- Rendering an item root produces a valid document that is a single list.
550`resolve_blocks = false` is the on-disk spelling and is used only to write files.
551`resolve_blocks = true` is the user-facing spelling: the reading pane and both editors show
552it, and it is the input to `splice`.
554### 5.2 Splice
556Every node belongs to exactly one block: the nearest ancestor-or-self that is one (the root
557counts). A block's text is its own title line, body and children down to, but not into,
558the blocks nested under it.
560`edit(node)` renders `render(node, 1, true)`, the fully resolved subtree, into an editing
561buffer in which **every line carries the id of the block that owns it**. The tag is
562invisible and travels with the line: a line typed after a tagged line inherits its tag,
563a line pasted somewhere takes the tag of the line above it, a deleted line takes its tag
564with it. Nothing about ownership is ever re-derived from the text.
566A block is **dirty** when any line it owns changed, or when the position of a block nested
567in it moved. `splice(block)` writes one dirty block:
5691. Collect the block's owned lines in buffer order, and put each nested block back as an
570 embed at the position of that block's title line — a heading embed if that title line is
571 a heading, a bare one if it is a bullet (§4.7). The result is exactly an edited
572 `render(block, 1, false)`: one file's text, embeds intact, ids never typed.
5732. Parse it: title lines, bodies, nesting, embeds. Exactly one root-level node; a `---`
574 line is body text.
5753. Apply the inverse shift: re-level sections by `+ (level(block) − 1)`; re-indent by
576 `+ indent(block)`.
5774. Canonicalize the touched nodes (§4.2). Frontmatter is not in the text and is copied
578 through unchanged; splice never creates, changes or removes a property.
5795. Compare the hash of the source span with the hash recorded when the block was last read.
580 If it differs, the file changed underneath: merge two-way against what is on disk
581 (§12.4), conflicts and all. Never overwrite silently.
5826. Replace the span atomically (§11.1). One file.
584Splice runs without being asked (§10.6): when the cursor moves from a dirty block into
585another block, after a pause in typing, on leaving the editor, before any outline verb,
586before a reload, and on quit or any other end of the app. A commit may write several
587files — one per dirty block — but each block is written by its own splice, and the law
588holds per block.
590Edge cases follow from the tags, not from rules:
592- Renaming a nested block is editing its title line; it is that block's line, so its file
593 is rewritten (and keeps its name, §6.4). No second step.
594- Deleting a nested block's title line deletes the block: the file goes to trash, and any
595 lines it still owned are re-tagged to the enclosing block — they become plain text of the
596 parent, which is what deleting a heading does to its content in any editor. This is the
597 one way a block's contents survive its death, and its properties do not.
598- Moving a nested block's title line (cut and paste in the editor) moves its embed; owned
599 lines that are no longer contiguous with the title line are re-tagged to the block they
600 now sit in.
601- Text moved from one block into another changes owner, and both blocks are dirty.
603### 5.3 Levels
605Heading levels are unbounded (§3.1), so splice never changes a node's spelling and never
606demotes anything.
608### 5.4 One function, three uses
610| Use | Call |
611|---|---|
612| Reading pane | `render(cursor, 1, true)` for reading |
613| Editing (`e`) | `render(cursor, 1, true)` with per-line owner tags → `splice` per dirty block |
614| Make a block (`s`, or the first property set in `a`) | `render(target, 1, false)` to `<prefix>~<name>.md` |
616---
618## 6. Blocks and files
620### 6.1 Making a block
622`make_block(node)`:
6241. Generate an id and a name (§6.4). The file is `<prefix>~<name>.md` in the vault root.
6252. Write `render(node, 1, resolve_blocks = false)` to the file atomically, with frontmatter
626 `id: <id>` and, if the block was made by setting a property, that property.
6273. Replace the node's span in its parent file with an embed at the node's position: a
628 heading embed at its level for a section, a bare `![[id]]` at its indent for an item
629 (§4.7). The text children around the node stay in the parent. A checkbox on the node
630 stays on its title line, now in the block's file (§4.5).
6314. Update the index.
633There is no inverse. A block stays a file until it is deleted (`d`, *clear done*, or
634another tool). To fold a block's text back into its parent by hand, yank its subtree
635from the reading pane, paste it at the embed, and delete the block; the app offers no
636single verb for this because it would have to throw the frontmatter away.
638A block is made when the first property is set on a node in the property editor (§10.6),
639or on `s` / *make block*. There is no other way.
641### 6.2 Constraints (enforced)
643- Every block is embedded from **exactly one** embed. A second embed for the same id is
644 a diagnostic and renders as broken. Cycles are a diagnostic.
645- A block file has one root, its first node at column 0; later column-0 lines are adopted
646 under it (§4.9). Its frontmatter has a valid
647 `id`.
648- Blocks may be nested: a block's file may itself contain embeds.
649- Embed lines have no children in the parent file.
650- The vault is flat. The app never creates a directory.
651- **Every filename the app writes is lowercase** (§6.4), temp and trash names included.
652 Titles inside files keep whatever case you typed; paths resolve case-insensitively
653 (§3.4), so `Homelab/NAS` and `homelab/nas` are the same target.
655### 6.3 What becomes a block, and when
657Only two things make a node a block: giving it a property in `a` (§3.2), or asking (`s`). The
658vault is
659therefore `root.md` plus exactly the blocks that have properties or that you split by
660hand. A note about a book has an author, so it's a file; a section of scratch prose has
661nothing, so it isn't; a task that needs a completion date becomes a file. For a phone-friendly
662inbox, make `Inbox` a block once; it stays one small file.
664### 6.4 Ids, names and filenames
666**Id**: 64 random bits rendered as four words of two syllables each, using Urbit's `@p`
667tables — 256 prefix syllables (consonant-vowel-consonant with vowels `a i o`) and 256 suffix
668syllables (vowels `e u y`) — each word `prefix suffix`, words joined by `-`:
669`racfer-hattes-dozzod-binwes`. No `~` sigil and no scrambling step, since the bits are random
670rather than sequential. Never derived from content or time. Stored as the `id` frontmatter
671key and validated against the syllable tables on read; a file whose `id` does not validate
672is ignored, and `fold check` says why.
674Collisions: 2⁶⁴ values, so two ids minted independently are never expected to collide; the
675index still checks and redraws locally, and the merge engine still reports an id collision
676as a diagnostic rather than silently merging two files, but neither path should ever run.
677Four words is fixed.
679Ids are random and never reused.
681**Name**: `slug(title)`: Unicode NFC → lowercase → whitespace runs to `-` → remove every
682character not in `\p{L}`, `\p{N}`, `-` → collapse repeated `-` → trim `-` → truncate to
68380 bytes at a character boundary → `untitled` if empty. Cyrillic titles keep Cyrillic
684names; there is no transliteration.
686**Filename**: `<prefix>~<name>.md`, where `<prefix>` is a leading run of the id's words —
687`racfer~order-new-switch.md` for id `racfer-hattes-mislup-nodrys`. Prefixes are unique
688**on their own**: the title plays no part. The run starts at one word and is the shortest
689that no other file in the vault already has as its prefix. If `racfer~notes.md` exists
690when a block with id `racfer-wolsun-…` is made — whatever its title — the new file is
691`racfer-wolsun~order-new-switch.md`; a third id sharing two words gets three. Existing files
692are never lengthened for a newcomer, and a prefix is never shortened. The full id is always
693a valid prefix, so the loop terminates. The `~` separates prefix from name unambiguously:
694words inside the prefix are joined by `-`, and `~` never occurs in a slug, so a filename
695splits without knowing how many words the prefix has. Because the prefix alone is unique, a title change
696can never collide with anything, and `racfer~` already tells you which file you are looking
697at before you read the rest. Cross-device collisions are found at merge time and resolved
698the same way (§12.2). The filename carries no identity: a file is parsed because of its
699`id` key, not its name, so `python-basics.md` with an `id:` is a block (and `fold
700check` asks for the prefix) while `racfer~order-new-switch.md` without one is ignored.
701Safe on APFS, ext4, Android storage and Windows; case-insensitive-safe because lowercase.
703**Names are set once.** The name is the slug of the title when the block is made, and a
704later title change does not rename the file. Under Syncthing a rename arrives on other
705devices as a delete plus a create; a phone that edits the old name meanwhile turns that
706into a sync conflict or an orphan, for nothing — the prefix alone identifies the file and
707nothing references the filename. A file renamed or titled by another tool is still found by
708its id. `fold check` reports filenames whose prefix is not a leading run of the id (a
709problem) and names that no longer match the title (stale, harmless); `fold check --fix`
710renames both, which is the one moment the app renames a file.
712### 6.5 Refile
714Refile is always a span operation: delete the subtree's span from the source file and insert
715the re-levelled text at the destination (§5). Refiling a block moves only its embed line;
716the file does not move. Any node may be refiled under any node;
717spelling is preserved. A task block's state travels with its file, not its embed.
719The subtree becomes the destination's **last child, clamped** by the ordering rule (§3.1): a
720section goes after everything; an item goes after the destination's last item or text child,
721just before its first section child. `Ctrl-Enter` (first child) is clamped the same way: a
722section as first child lands just after the destination's last item. Text children of the
723source's old parent stay behind.
725**Archive.** Archiving is refile with a fixed destination: the subtree is appended as the
726last child of the top-level section titled `Archive` (case-insensitive, like `Inbox`;
727created as the last top-level section of `root.md` if missing). Nothing else happens — no
728path is recreated; if you want structure inside the archive, make it. `Archive` is an
729ordinary section: refile things back out, rename it (the next archive creates a fresh one),
730delete it. Nothing in the archive is hidden or dimmed.
732---
734## 7. Capture
736- The inbox is the top-level section titled `Inbox` (case-insensitive). If none exists,
737 capture creates `# Inbox` as the last top-level section of `root.md`. If several exist,
738 the first in `root.md` order wins and `fold check` reports the rest.
739- **Capture** (`c` in the TUI, `fold capture`, a global hotkey via the CLI) appends an item
740 under today's day: the child section of `Inbox` titled with today's date, `YYYY-MM-DD`,
741 created on demand as the last child. `--task` or a leading `[ ]` makes it a task.
742 `--to <target>` captures under any node instead. A captured item is clamped like any
743 inserted item (§3.1): under a target with section children it lands just before the first
744 of them. Capture never creates a block; dates
745 and properties are set in the TUI afterwards.
746- Days are ordinary sections, appended in order, so the inbox reads oldest to newest. They
747 can hold prose, tasks and sub-sections like any section, and `2026-09-11` addresses
748 one by title (§3.4). Items placed directly under `Inbox` by hand (or by a phone
749 editor appending to the file) are fine; they're simply undated.
750- The day title is the only creation timestamp an inline node gets; the app tracks nothing
751 finer (§12.6).
752- `c` in the TUI lands on today; the inbox itself is the refile queue.
754---
756## 8. Tasks
758### 8.1 States
760| Node | open | done |
761|---|---|---|
762| not a block | `- [ ] Title` / `## [ ] Title` | `[x]` |
763| block | `- [ ] Title` / `## [ ] Title` | `[x]` + `done: <date>` in frontmatter |
765Any node can be a task. A checkbox task has exactly this much state and nothing else. Its
766completion date is not recorded; if you need one, make the task a block so toggling
767writes `done:` (§8.2).
769### 8.2 Task blocks
771A task that needs a date, a property, a completion record, or a body with structure becomes
772a block (§6.1). Its checkbox moves into the block's file with its title line; the embed in
773the parent is `![[id]]`. Toggling from anywhere (outline or embed) rewrites that checkbox and
774stamps or removes `done:`.
775The reading pane renders a resolved task embed on one line:
777```
778☐ Order new switch due 2026-09-20
779```
781Setting a property on an inline task in `a` is how it becomes a block (§6.1); nothing
782about that is visible afterwards except the `▤` marker.
784### 8.3 Hierarchy semantics
786Indentation is ownership. A nested task is a subtask; indented plain lines are notes on the
787task. There is **no roll-up**: completing a parent doesn't complete children and vice
788versa. Every node shows derived counts (`open/total`) for its subtree; a task section shows
789its own state as well. A task block's sub-nodes live in its file, and the parent list
790shows only the embed.
792### 8.4 Dates
794Dates are ISO, `YYYY-MM-DD`. The property editor refuses anything else for `due` and
795`done`; other keys take any text.
797### 8.5 Finished tasks
799Done items stay where they are: one line each, in the context that gave them
800meaning, which is the cheapest history there is. The panes dim them, and `zd` toggles
801hiding them (remembered with the view, §10.1). The selection stays on its node; one
802hidden with them gives way to its next shown sibling, else the row above it.
804When a list is genuinely finished, *clear done* (palette) trashes every done item with no
805open descendants under `target` (default: current zoom root), unless a conflict copy of
806it stays (§12.5); task blocks'
807files go to trash too. For whole finished subtrees — a completed project, a concluded
808meeting series — `za` refiles them under `Archive` (§6.5). The trash (§11.5) and file versioning (§12.6) backstop both.
810### 8.6 Recurrence
812Out of scope for 1.0. Nothing is reserved for it.
814---
816## 9. Query language
818Not in 1.0. Search is the filter box (§10.5): fuzzy title match plus full-text match over
819the whole vault, live, no syntax. A query language is future work (§17, §19 decision 16); the
820```` ```query ```` fence is reserved for it (§4.8).
822---
824## 10. TUI
826### 10.1 Layout and the pointer
828```
829 fold › Homelab › NAS ⌕ Filter + Capture ↶ Undo ↷ Redo ☰ Commands ? Help
830╭ Outline ───────────────────────╮╭ ZFS layout ▤ ─────────────────────────── ✎ Edit ─ ⋯ ─╮
831│▾ Homelab 2/4 ││ # ZFS layout │
832│ ▾ NAS 1/2 ││ ⚑ since 2024-03 │
833│ ▾ ZFS layout ▤ 1/2 ⋯││ │
834│ ▾ ☐ Snapshot policy ││ Mirrored pairs, no raidz. Snapshots hourly. │
835│ ▸ Networking 1/2 ││ ## ☐ Snapshot policy │
836│▾ Inbox ││ - hourly, keep 24 │
837╰────────────────────────────────╯╰───────────────────────────────────────────────────────╯
838 moved “Label the cables” ⚠ 1 conflict root.md ✓ saved
839```
841Four regions, each of them live under the pointer:
843- The **top bar**: the breadcrumb of the zoom root (`fold` is the vault root; every
844 segment is a link that zooms there), and buttons for *Filter*, *Capture*, *Reading
845 pane*, *Undo*, *Redo*, *Commands* and *Help*. Buttons shrink to their icons when the bar
846 is narrow; a toggle's button looks pressed while it is on.
847- The **outline pane**: titles, fold markers (`▸ ▾`), task glyphs (`☐ ☑`), the block
848 marker (`▤`), a due date and the open/total count of the tasks below (a leaf shows no
849 count). Done tasks are dimmed and struck through; sections are bold. Long titles end in
850 `…`. Beside the reading pane, the date and count sit at the pane's right edge and rows
851 show titles only. Without it, they follow the title, and after them, dimmed, comes the
852 first line of the node's own text, so no prose is out of sight. A conflict copy
853 (§12.5) has `⚠`, in the warning colour, for `▤`, and, in place of its text, whose copy
854 it is: *other device · PHONE 09-27 10:00*. It starts folded until unfolded or zoomed
855 into, a fold the view does not remember.
856- The **reading pane**: `render(target, 1, true)`, the zoom root's or else the selected
857 node's, with light Markdown styling (§10.9); a block's properties appear as a dimmed
858 `⚑` line under its title, never as text, and a conflict copy's title line ends in its
859 `⚠` and whose copy it is, as its row does. Its border carries the node's title and the
860 buttons *Edit* and `⋯` (the node menu). While editing, the pane is the editor (§10.6) and
861 its buttons are *Done* and *Revert*.
862- The **status bar**: the last message on the left (at start, a hint at the gestures and
863 `?`, cut down to *right-click for actions · ? help* where it doesn't fit); on the right
864 the unresolved conflicts (click to resolve; lit while new ones wait, §10.7), *done
865 hidden* when `zd` is on (click to show), the file, and the save state. A verb's message
866 names the node it acted on, in the menus' words, with the next key where one helps —
867 *deleted “Homelab” (12 nodes) · u undoes*, *moved “rack” to “NAS” — placed before the
868 sections* when the ordering rule chose the place (§3.1); in the reading pane, whose keys
869 are its own (§10.4), the step is the menu of the line's node: *copied “NAS” · m, then
870 choose Paste after*; in the editor, where every key is text (§10.6), it is the
871 pointer's, short enough to keep the node and the item to choose at 80 columns: *copied
872 “NAS” · right-click, Paste after*, *deleted “rack” · click ↶ Undo* — and names no file
873 or id (§1, 3b), but for undo or redo refusing
874 (§10.10); where the bar is short, the right gives way first, the ⚠ count down to its
875 number (*⚠ 1*), then the file, until the message fits; then the names are cut down,
876 the longest first, before the words around them. Once a message is some 5 s old and a key
877 or click has come since, the next step takes its place: the keys of what is on screen —
878 *n new · e edit · x done · m menu · / find · ? help* in the outline, *e edit · Enter
879 zoom/follow · Tab outline* in the reading pane, *Esc done · Ctrl-S save · Ctrl-Z undo* in
880 the editor (*i insert · :q! revert · :wq done* in Vim and Helix, where `Esc` never
881 leaves), *n add · Enter change · d delete · Esc close* in the property form, and each
882 popup's own — cut down by whole parts where they don't fit, the first and the last kept,
883 so the way on (*? help*, *Esc done*, *:wq done*) stays. An error or a refusal
884 (*error: …*, *can't …*, *… refused: …*) stays until a key or click that comes once it
885 is that old. The greeting is the outline's, and gives way at once to the editor's or a
886 popup's keys. While a key sequence is half typed, the bar says what can follow it:
887 *z… p pane · w wrap · d hide done · r raw · a archive*, cut down from its end where it
888 doesn't fit; with done hidden, *d show done* comes first.
890The outline fills the screen. The reading pane is hidden until asked for: `zp` or the top
891bar's `◨` (*Reading pane*) shows it, `Tab` shows it and moves focus there, and the editor
892(§10.6) always opens in it, hiding it again when done if it was hidden before. Shown, the
893layout splits: outline on the left at a third of the width (minimum 30 columns); the border
894between the panes is a handle — drag it to resize. Below 80 columns the panes stack.
896**The view is remembered** per vault, in `$XDG_STATE_HOME/fold/views/`: whether the
897reading pane is shown, wrapping, hidden done tasks, the editor's keymap, the divider's
898place, the folds and the zoom. None of it is in the vault; another device keeps its own.
899A fold or zoom whose node has gone is dropped.
901**Long lines wrap** in the reading pane and the editor. Prose breaks after a space, and a
902wrapped list item's continuation rows hang under its text, not under its bullet or
903checkbox; a word longer than the pane breaks where it must. Lines inside fenced code break
904at the pane's edge instead, one column early, with `↪` marking each break, so code is
905never reflowed. Every screen row of a line is that line to the pointer: a click on any
906row selects it or places the cursor where it lands. `zw` (*Wrap lines*) turns wrapping
907off, and long lines are cut at the edge. The outline never wraps: a long
908title ends in `…`.
910**The pointer.** Everything a key does, the pointer does too, and the screen shows where:
912| Gesture | Where | Does |
913|---|---|---|
914| click | an outline row | select it (the reading pane follows) |
915| click | `▸` / `▾` | fold / unfold |
916| click | `☐` / `☑`, in either pane | toggle the task |
917| click | a conflict copy's `⚠`, in either pane | the conflict view at its pair (§10.7) |
918| click | `⋯` (shown on the selected and the hovered row) | the node menu |
919| click | a breadcrumb segment | zoom there |
920| click | a link in the reading pane | open it |
921| click | a line in the reading pane / the editor | put the cursor there |
922| double-click | an outline row | zoom into it |
923| double-click | a line in the reading pane | a heading: zoom into it; an embedded block: follow it; anything else: open the editor with the cursor on that line |
924| right-click | a row or a line | the node menu for that node |
925| drag | an outline row onto another row's **title** | move it **into** that node, as its last child |
926| drag | an outline row onto the space **left of** another row's title | move it **before** that node, as its sibling |
927| wheel | either pane, the editor, any list | scroll what is under the pointer; the selection stays put |
928| drag | the border between the panes | resize them |
929| click | outside any popup | close it |
931While dragging, the target row is highlighted (*into*) or marked with `▶` and a bar
932(*before*), the row being moved is dimmed, the status bar spells out the move, and the pane
933scrolls when the pointer reaches its edge. Both drops are clamped by the ordering rule
934(§3.1) and say so when they are; a drop into the node's own subtree is refused, and so is
935one into a conflict copy it is not in, which keeping ours trashes (§12.5): over the copy's
936title or a row in it, nothing is marked and the status bar says why. One drag is one undo
937step.
939**No key acts out of sight.** The wheel leaves the selection, and the reading pane's cursor,
940where they are, even out of view. A key that acts on one — in the outline
941`x t d s J K > < ~ za r p P`, `e a m y` and the folds; in the reading pane `x`, `Enter` and
942`e a m o` — first scrolls it back into view, a third of the way down. A key that changes
943nothing then acts; one that changes something stops there, and until the next key or click
944the status bar says what a second press does: *press d again to delete “Replace fan”*.
945Pressed again, with the node in view, it acts. The pointer acts where it points and is
946never held back.
948**The node menu** (right-click, `⋯`, or `m`) lists every action on a node, each with its key:
949*Edit · Zoom in · Properties… | New sibling · New child | Done / reopen · Task on / off ·
950Heading ↔ bullet · Make block | Move up · Move down · Indent · Outdent · Move to… · Archive |
951Copy · Paste after · Paste before · Delete*, and on either side of a conflict pair, last,
952*Resolve conflict…*: the conflict view at that pair. Moving the pointer onto an item
953highlights it, and a click runs it; `↑`/`↓` and the wheel move the highlight on, whatever
954item the pointer rests on. On a screen too short for it the separators go first, then the
955list scrolls with the selection.
957**Pickers instead of typing.** Where an action needs a node — *Move to…*, *Go to…* — the
958prompt is a list of every node, title first and path dimmed, narrowed as you type (title
959prefix, then title, then path) and picked with a click or `↑`/`↓` and `Enter`. A typed id,
960path or title (§3.4) still works. *Move to…* leaves out the moving node's own subtree, and
961its conflict pair's, which moves with it, and conflict copies, which keeping ours trashes
962(§12.5); *Go to…* marks a node in one `⚠`.
964Every popup — node menu, prompt, properties, filter, commands, help — has its buttons on
965its bottom border (*OK*, *Close*, …), a list you can click and scroll, and closes on a click
966outside it or `Esc`. It covers the panes: a click, right-click or wheel inside it never
967reaches what is behind it, so a right-click there opens no node menu.
969### 10.2 Modes
971`normal` (default), `edit` (the built-in editor over a subtree's Markdown, saving as you go), `filter`
972(`/` box), `picker` (the command palette), `properties` (the property form), `help`,
973`conflict` (§12.5). The node menu and prompts open over any mode. `Esc` — or a click outside
974the popup — closes the topmost thing.
976### 10.3 Outline pane — normal mode
978| Key | Action |
979|---|---|
980| `j` / `k` | next / previous visible node |
981| `h` | collapse; if collapsed or leaf, go to parent |
982| `l` | expand; if expanded, go to first child |
983| `H` / `L` | collapse / expand all under cursor |
984| `zd` | toggle hiding done nodes (both panes) |
985| `zr` | toggle raw mode: exact source in the reading pane, frontmatter included |
986| `zw` | toggle wrapping of long lines in the reading pane and the editor (§10.1) |
987| `zp` | show / hide the reading pane (§10.1) |
988| `za` | archive: refile subtree under `Archive` (§6.5) |
989| `-` | go to parent |
990| `{` / `}` | previous / next sibling |
991| `gg` / `G` | first / last visible node |
992| `Ctrl-d` / `Ctrl-u` | half-page down / up |
993| `Enter` | zoom: reading pane shows the cursor node; reading pane takes focus |
994| `Backspace` | zoom out to parent |
995| `>` / `<` | demote / promote: become the last child of the previous sibling node / the next sibling of the parent, both clamped by the ordering rule (§3.1): an item promoted out of a section lands just before the parent's first section sibling. Spelling unchanged. `>` is refused under a conflict copy, which keeping ours trashes; a node and its conflict copy go as one, from either side (§12.5) |
996| `~` | toggle spelling, section ↔ item (subtree unchanged). The node moves to its parent's boundary (§3.1): an item respelled as a section becomes the first section child, a section respelled as an item the last item child, so no sibling changes parent. Refused on either side of a conflict pair until it is resolved (§12.5) |
997| `J` / `K` | move node down / up past the next / previous sibling node; text children stay put. A node and its conflict copy (§12.4) move as one, and a node moves past them as one. An item never moves below a section sibling, nor a section above an item: the move is refused with a message |
998| `n` / `N` | new sibling after cursor / new last child, spelled like the cursor node / like the last child node (a section if the parent has section children): inserts an empty node and opens it with `e` |
999| `e` | edit the subtree's Markdown in the built-in editor; saves as you go (§10.6) |
1000| `a` | property editor: a form over the node's properties (§10.6); first property on a plain node makes it a block |
1001| `x` | toggle task open / done: the checkbox, plus `done:` on a block |
1002| `t` | toggle task-ness: adds or removes the checkbox (and a block's `done:`) |
1003| `s` | make the node a block |
1004| `y` / `d` | yank / delete subtree into the register (delete goes to trash too, and takes the node's conflict copies with it, §12.5) |
1005| `p` / `P` | paste register after / before cursor as sibling, clamped by the ordering rule (§3.1) |
1006| `r` | refile: fuzzy-pick a destination; `Ctrl-Enter` = as first child |
1007| `c` / `C` | capture to the inbox as bullet / as task |
1008| `/` | filter box (§10.5) |
1009| `m` | the node menu (§10.1) |
1010| `:` | command palette (§10.8); `?` or `F1` help |
1011| `u` / `U` | undo / redo |
1012| `q` | quit (nothing is ever unsaved in normal mode) |
1014A second key that follows the first in no sequence (`zq`) does nothing and says so: *zq
1015does nothing · after z press p, w, d, r or a*; `Esc` or a click lets the first key go. A
1016few keys fold has no use for, where one often reaches for them, say what to press instead:
1017`i` (*i does nothing here: e edits*), `o` (*n adds a node below*), `Delete` (*d deletes*),
1018`Ctrl-Z` (*u undoes*), `Ctrl-F` (*/ finds*); any other key that does nothing says nothing.
1020### 10.4 Reading pane — normal mode
1022| Key | Action |
1023|---|---|
1024| `j` / `k`, `Ctrl-d` / `Ctrl-u`, `gg` / `G` | move / scroll |
1025| `Enter` | on a heading: zoom into it (`x` toggles a task heading); on an embed: follow; on a task item: toggle |
1026| `Backspace` | zoom out |
1027| `x` | toggle task under cursor |
1028| `e` / `a` | edit the node under cursor: its subtree's Markdown, or its properties, as in the outline |
1029| `o` | open external link under cursor (`xdg-open` / `open`) |
1030| `[[` / `]]` | previous / next heading |
1031| `/` | in-block search; `n` / `N` next / previous match |
1032| `:` `?` `F1` `m` `c` `C` `u` `U` `z…` | as in the outline; `m` is the menu of the node under the cursor; `i`, `Delete` (*m, then choose Delete*), `Ctrl-Z` and `Ctrl-F` (*/ searches*) say what to press instead |
1034### 10.5 Filter box
1036`/`, or *Filter* in the top bar, opens a popup with an input and the hits below it:
1037fuzzy title match (the typed letters in order) and full-text match over every text child, each hit shown as its
1038title with its path dimmed, and `⚠` after the title of a hit in a conflict copy (§12.5).
1039Clicking a hit — or `↑`/`↓` and `Enter` — unfolds its ancestors and selects it. `Esc` or a
1040click outside closes. Creating nodes is `n` / `N` (§10.3).
1042### 10.6 Editing
1044There is one way to change text: edit the Markdown of the selected subtree. `e`, *Edit* on
1045the reading pane or in the node menu, or a double-click on a line of text shows
1046`render(cursor, 1, true)` — the node's title line, body and children, with every
1047nested block inlined so that no embed, id, frontmatter or file boundary appears.
1049**Built-in** (`e`): a plain multi-line text box over that text, in the reading pane. There
1050is nothing to commit and nothing is locked. Each line carries its owning block invisibly
1051(§5.2); you type anywhere, including across what happens to be a file boundary, and the
1052app keeps track. Blocks are saved on their own:
1054- when the cursor moves out of a block that has changed;
1055- after 750 ms without a keystroke;
1056- on `Esc` or *Done* (back to normal mode), on any outline verb, before a reload caused by
1057 an external change, and on quit;
1058- when the app ends any other way: `SIGTERM`, `SIGHUP` (a closed window, a dropped ssh
1059 session), `SIGINT`, an I/O error, a panic. A second `SIGTERM` or `SIGINT` ends it at
1060 once, unsaved, as the signal would: the way out while a write waits on a terminal that
1061 reads nothing. A `SIGHUP` before one does not make it a second.
1063An outline verb that writes re-renders the editor over what it wrote, so the save before it
1064deletes a block cut there and not pasted back (§5.2). One that writes nothing — a copy, a
1065zoom, help, the palette, the filter, a view toggle, or a prompt or the property form until
1066it writes — leaves the editor as it is, a cut block still to paste.
1068A save shows in the status bar's save state (§10.1), not as a message, but for `:w` and
1069`Ctrl-S`, which are asked for and answer *saved*, and one that an outside change forced,
1070which the news of what came in mentions first, so that where the bar is short the list
1071of it gives way: *↻ typing saved; changed outside fold: Inbox (+1 item)* (§11.2). News
1072of a sync conflict says what to do and no more (§10.7).
1074The pane's border shows the title of the block the cursor is in, *⚠ conflict copy from
1075PHONE* after it in a conflict copy (§12.5), and a dot while something is unsaved, and lines
1076of other blocks in the subtree are drawn a shade dimmer — the only hints that blocks
1077exist. The border's text stays left of `⌨` and the buttons: where that is short, the title
1078gives way first, to its first letters, then the words after ⚠ (*⚠ copy from PHONE*, then
1079*⚠*), then *Editing*; the ⚠, the dot and the Vim or Helix mode stay whole.
1080*Revert* (or `:q!`) discards changes made since the last save.
1082Text a save cannot take — its block changed on disk under it (§5.2) — stays in the editor,
1083and is never dropped without a copy unless asked twice: *Revert* first writes the editor's
1084whole text to the trash as `unsaved-<title>.md` (§11.5) and the status line names the entry,
1085and the app ending any way but a quit does the same, saying where once the terminal is back:
1086a panic in the save itself too, whether an autosave, `Esc` or leaving a block ran it.
1087Where the trash cannot be written, *Revert* drops nothing and says so, and a second *Revert*
1088on the same text drops it. However it ends, but for that second signal, the app leaves the
1089terminal as it found it, a panic's message printed after.
1091**Keymaps.** The editor speaks one of three keymaps, over the same text, cursor, selection,
1092clipboard and undo:
1094- **normal** (the default): a conventional editor in the manner of micro. Always typing;
1095 arrows, `Home`/`End`, `PgUp`/`PgDn`, `Ctrl-←/→` by word; `Shift` with any of them
1096 selects; typing replaces the selection. `Ctrl-C`/`X`/`V` copy, cut, paste (with nothing
1097 selected, the current line); `Ctrl-Z`/`Y` undo, redo; `Ctrl-A` select all; `Ctrl-K` cut
1098 the line; `Ctrl-D` duplicate; `Alt-↑/↓` move lines; `Tab`/`Shift-Tab` indent;
1099 `Ctrl-F` find, `Ctrl-N`/`Ctrl-P` next, previous; `Ctrl-S` save; `Ctrl-E` a `:` command;
1100 `Esc` clears the selection, then leaves.
1101- **vim**: normal, insert, visual and visual-line modes; counts; motions `h j k l w b e W
1102 B E 0 ^ $ gg G { } % f t F T ; ,`; operators `d c y > <` with any motion, doubled for
1103 lines, and text objects `iw aw i" a" i' a' i( a( i[ a[ i{ a{ i< a< ip ap`; `x X s S D C Y
1104 r J ~ p P o O i a I A u Ctrl-R .`; `/ ? n N * #`; `ZZ`, `ZQ`.
1105- **helix**: selection first — `w b e W B E` select, `x` selects (and extends by) lines,
1106 `% ; Alt-;`, `f t F T`, `gg ge gh gl gs`, `mi` / `ma` + object, `v` to extend; `d c y p P
1107 R r ~ > < J` act on the selection; `i a I A o O`; `u U`; `/ ? n N *`.
1109With wrapping on (§10.1), the normal keymap's `↑`/`↓`/`PgUp`/`PgDn` and Helix's `j`/`k`
1110move by screen row through a wrapped line, as micro and Helix do; Vim's `j`/`k` move by line,
1111and `gj`/`gk` by screen row.
1113All three take `:` commands — `:w` save, `:q` / `:wq` / `:x` done, `:q!` / `:e!` revert,
1114`:N` go to line — and in all three a click puts the cursor where it lands, a drag
1115selects, a double-click selects a word, the wheel scrolls, and text pasted into the
1116terminal is inserted as typed. Copying also sets the system clipboard (OSC 52). Undo
1117inside the editor is the editor's own; each save is still one entry in the op log (§10.10).
1118`F1` shows the help, `?` being text here.
1120The keymap is `$FOLD_KEYS` (`normal`, `vim`, `helix`), or `--keys`; the *Editor keys*
1121action, or the `⌨` label in the editor's border, switches it, and the choice is remembered
1122with the view (§10.1) unless `--keys` or `$FOLD_KEYS` chose the keymap for this run. When
1123editing ends, focus returns to the pane it came from. Undo inside the editor is the
1124editor's own; each automatic save is one entry in the session op log (§10.10). The box is
1125not a Markdown editor and stays one.
1127Properties are not text. `a`, or *Properties…* in the node menu, opens the **property
1128editor**: a small form listing the
1129node's keys and values: click a value to change it (the prompt starts with the current
1130value), `✕` deletes an entry, *Add* adds one. `due` and `done` accept
1131only ISO dates; `id` is not shown. Setting the first property on a node that is not a
1132block makes it one (§6.1), silently except for the `▤` marker; deleting the last one leaves
1133the block as it is. Frontmatter lines the app does not understand (nested structure,
1134comments) are listed read-only and preserved.
1136`n` / `N` insert an empty node and put the cursor on it in the editor, so creating and
1137editing are the same gesture. `x`, `t`, `~`, `>`/`<`, `J`/`K`, `r` and `d` are the
1138structural verbs; they exist because toggling a checkbox should not require the editor,
1139and each saves the editor first.
1141### 10.7 Conflict view
1143Opened by the status bar's ⚠ count or *Resolve conflicts*; at a pair, by a copy's `⚠` or
1144*Resolve conflict…* in either side's node menu; and on its own when a `.sync-conflict-*`
1145file is detected or a splice hits a changed span — on the first new pair in the outline,
1146and only from an outline at rest: normal mode, nothing open over it, no key, paste or
1147click for 2 s. A busy user is not interrupted: the mode stays, the editor keeps its text,
1148the status line names the node — *sync conflict in “NAS”: click ⚠ to resolve*
1149— and the ⚠ count is lit until the view opens, from a click or on its own once the outline
1150is at rest. Opened on its own, for its first half second it ignores `o t b e`, meant for
1151what was there before. Pairs that come in while it is open leave it on the pair it shows.
1153It shows each conflict pair (ours in place, the `conflict:` block right after it) side by side; `o` keeps ours,
1154`t` keeps theirs, `b` keeps both, `e` edits, `n` / `N` next / previous, `u` / `U` undo / redo,
1155`Enter` finishes. Each choice names its node — *kept theirs for “NAS” · u undoes* — and is
1156one undo step.
1157The view's top bar carries the same as buttons — *Previous*, *Next*, *Keep ours*, *Keep
1158theirs*, *Keep both*, *Edit ours*, *Close* — and the two versions sit side by side, this
1159device's on the left.
1160Unresolved pairs remain as `conflict:` blocks and stay listed in the status
1161line until resolved (§12.5).
1163### 10.8 Command palette
1165`:`, or *Commands* in the top bar, opens a popup listing every action the TUI has — key,
1166name, what it does — filtered as you type: names that start with the query first, then
1167names and descriptions that contain it, then fuzzy matches. A toggle shows its state
1168beside its name (*Reading pane · on*). A click or `Enter` runs the highlighted action; if
1169it needs an argument — a node for *Move to…* or *Go to…*, text for *Capture* — its prompt
1170follows (§10.1). `Esc` or a click outside closes.
1172There is no command syntax: nothing is typed except the search and the argument. Every
1173action is in the palette under a readable name, and the four that have no key or button
1174live only there: *Clear done*, *Canonicalize*, *Merge sync conflicts*, *Resolve
1175conflicts*. `?` is help: the pointer gestures first, then the keys. Where it is taller
1176than the screen it scrolls, a bar on its right border: `j`/`k`, `↑`/`↓` or the wheel.
1178### 10.9 Markdown styling in the reading pane
1180Line-based, not a full renderer: headings coloured by level, task checkboxes shown as `☐ ☑`
1181(clickable; done lines dimmed and struck through), links underlined (clickable),
1182`` `code` `` tinted, `**bold**` and `*em*` styled, quotes in colour.
1184**Fenced code** sits on a tinted background, its fence dimmed and its info string in the
1185accent colour, and is **syntax-highlighted** with tree-sitter when the info string's first
1186word names a known language. The app compiles in every tree-sitter grammar published on
1187crates.io that builds against its tree-sitter version and has a highlights query — about
1188140 languages, from Ada to Zig, including every mainstream programming, shell, markup,
1189config, query and hardware-description language that has such a crate — keyed by the
1190language's name and its usual fence aliases (`rs`, `py`, `js`, `ts`, `sh`, `c++`, `c#`,
1191`kt`, `rb`, `hs`, `ex`, `yml`, `makefile`, `dockerfile`, `sql`, …). Where a grammar crate
1192ships a highlights query without exporting it, the app carries a copy
1193(`crates/fold-tui/queries/`, with its provenance). `rust,ignore`, `{.python}` and `sh
1194title=x` work too. Each grammar is loaded on first use; the block is highlighted as a whole
1195(so a string or comment spanning lines is right) and cached until its text changes. Any
1196other language, or a block its grammar cannot parse, is shown in the
1197plain code colour. Highlighting is the reading pane's only: the editor is a plain text box
1198(§10.6). Raw text is
1199never hidden: `#`, `-`, `**` and link targets stay on screen, dimmed. `zr` shows exact
1200source, frontmatter included.
1202### 10.10 Undo, redo, saving
1204Every mutation is an operation with an inverse (span edits, file create/move/delete,
1205including making blocks). The session op log powers `u` / `U`. Outline verbs write
1206immediately and atomically; the built-in editor writes each dirty block within 750 ms of the
1207last keystroke, or sooner when the cursor leaves it (§10.6), so the only unsaved text at any
1208moment is under a second of typing in one block.
1210An op-log entry records **exactly the files the operation touched**: for each, its text
1211before and after, where *absent* stands for a file the operation created or deleted (making
1212a block, trashing one). An operation that changed nothing leaves no entry, so it does not
1213clear the redo stack. Undo checks every touched file against its *after* text, and redo
1214against its *before* text; if any differs — another editor or a sync changed it since — it
1215refuses with a message naming the file, writes nothing, and keeps the entry. An entry is
1216named in words, by what was done to which node — *mark “rack” done*, *delete “Homelab”*,
1217*edit “NAS”* for an editor save — and undo and redo say it: *undone: mark “rack” done*,
1218*undo refused: root.md changed since mark “rack” done; not overwriting*. Files the
1219operation did not touch are never written by undo, so an external edit to them survives.
1220A block cut in the editor and not pasted back (§5.2) is trashed in the entry of the save
1221that wrote its embed out, even when a later save, *Revert* or a change from outside that
1222re-renders the editor (§11.2) is what deletes it, so one undo puts back its embed and its
1223file together, or refuses both where that change is in the way. One pasted back after a
1224save wrote its embed out makes that save, the one that writes the embed back and those
1225between one entry, so no undo stops where its file is embedded nowhere: one undo puts it
1226back where it was. Where a file they touched changed from outside between two of them,
1227they stay apart, as undoing them as one would drop that change.
1229---
1231## 11. Storage, I/O and safety
1233### 11.1 Writes
1235- **Atomic**: write to `.<name>.fold-tmp` in the same directory, `fsync`, `rename`.
1236 Never truncate in place.
1237- **Span-preserving**: an edit replaces exactly the bytes of the affected node(s) — for a
1238 property change, exactly the frontmatter lines that changed. Every other byte is copied
1239 through unchanged. After a write the file is re-parsed.
1240- **mtime**: files that are not written keep their mtime. Untouched nodes are never
1241 re-canonicalized, so a no-op session leaves no trace on disk.
1242- Line endings and a trailing newline are normalized only within the rewritten span.
1244### 11.2 Watcher and reload
1246`notify`-based recursive watcher on the vault, 200 ms debounce, ignoring the patterns in
1247§11.4. Only writes count — a file created, written, renamed or removed — never a file
1248opened or read, as every reload reads them all. A reload runs only when the files differ
1249from what the app last read or wrote (a file's text changed, a file came or went) or a new
1250`*.sync-conflict-*.md` file appeared. The app's own writes, seen once they land, reload
1251nothing.
1253- A changed file that the user is not editing is re-parsed; the cursor is re-attached by
1254 id, then key (§3.4), then the deepest step of the key that still exists.
1255- The status line says what came in, by the top-level nodes it touched:
1256 `↻ changed outside fold: Inbox (+1 item)`.
1257- A changed file with a built-in edit in progress: the editor saves its dirty blocks first;
1258 a block whose span hash no longer matches is merged two-way (§5.2 step 5), then the file
1259 is re-parsed and, where the text the buffer shows changed, the buffer re-rendered around
1260 the cursor; a block cut there and not pasted back is deleted then (§5.2). A change that
1261 leaves that text as it was — to another node of the same file, as the phone adding to
1262 the Inbox in `root.md`, or to a file the buffer does not hold — leaves the editor as it
1263 is, a cut block still to paste.
1264- A new `*.sync-conflict-*.md` file starts the merge flow (§12). What it merges without a
1265 pair is said as above, at startup too; a copy that brings nothing in, one it leaves alone
1266 or one the same as its file, says nothing, nor that the editor saved first for it. A copy
1267 it leaves alone or fails on is no change by itself, and is tried again with the next
1268 change. A copy that lands while a reload runs, as the next of a batch Syncthing delivers
1269 a moment apart, is new to the next reload. A block cut in the editor and not pasted back
1270 is moved on this device: a copy from before the cut, as the phone's `root.md` with an
1271 item added to the Inbox, still embeds it where it was, and is merged without that embed
1272 (§12.3): the block is not put back there, and the paste embeds it once.
1273- A deleted file that was a block marks its embed broken; nothing is written.
1274- Editing the same vault in Helix at the same time is a supported workflow.
1276### 11.3 Index
1278The index — ids, names, paths, embeds, task table, property table, spans, per-file hashes —
1279lives in memory and is rebuilt from the vault on every start by parsing every `.md` file.
1280Nothing is persisted: no cache directory, no shadow copies, no state that could go stale or
1281disagree with the files. A file is re-indexed whenever the watcher reports it changed.
1282Startup cost is the parse (§15.5), which is why the parser has a budget.
1284Nothing under the vault directory is written by the app except Markdown and temp files
1285during atomic writes.
1287### 11.4 Ignore patterns
1289Never parsed, never written, never deleted: dotfiles and dot-directories (`.git`, `.jj`,
1290`.stfolder`, `.stversions`, `.stignore`, `.obsidian`), `.syncthing.*.tmp`, `*.fold-tmp`,
1291`*.tmp`, and any file not ending in `.md`. `*.sync-conflict-*.md` is parsed only by the merge
1292engine.
1294### 11.5 Trash
1296Deleted subtrees and resolved `conflict:` blocks are written to
1297`$XDG_STATE_HOME/fold/trash/<timestamp>-<id-or-name>.md` (device-local, never synced) before
1298removal; editor text no save could take, to `<timestamp>-unsaved-<title>.md` (§10.6).
1299`fold trash list|restore` manages it. `restore` moves an entry back into the vault, but an
1300`unsaved-` entry has no id, so the vault would ignore it as a file (§6.4): it is printed
1301instead, and stays in the trash. The content decides, not the name: a block file renamed
1302`unsaved-….md` (§3.4) keeps its id and is moved back. The app never deletes user content
1303without a trash copy.
1305---
1307## 12. Sync and conflict merge
1309### 12.1 Working assumptions about Syncthing
1311- Sync is per file, block-level, and conflict-unaware: concurrent edits to one file produce a
1312 winner plus a `name.sync-conflict-<date>-<time>-<device>.md` copy of the loser.
1313- Therefore: keep concurrently-edited things in different files where possible (a
1314 `Inbox` as a block; blocks), and make the app the thing that understands conflict files.
1315- The vault's `.stignore` should contain at minimum: `.git`, `.jj`, `*.fold-tmp`, `*.tmp`.
1317### 12.2 Detection
1319The watcher (or `fold merge`, or the startup scan) finds `X.sync-conflict-*.md` next to
1320`X.md`, as the files are then: an `X.md` that came in with its copy is merged into. Several
1321conflict files for the same `X.md` are merged oldest first.
1323Before merging, the engine compares the two files' `id` keys. If they differ, this is not a
1324conflict but a **prefix collision** — two blocks that happened to get the same filename
1325on different devices — and the conflict file is simply renamed with a longer prefix of its
1326own id, per §6.4, until its prefix is unique. The same happens, without a sync conflict, when
1327a reload finds two blocks sharing a prefix. Both embeds resolve by id; nothing is merged
1328and nothing is lost.
1330### 12.3 Merge inputs
1332Ours `O` is `X.md`; theirs `T` is the conflict file, without its embeds of the blocks cut
1333in the editor and not pasted back (§11.2) — or, for an in-session conflict (§5.2 step 5),
1334the text the user just edited. There is no stored common ancestor (§11.3), so every merge
1335is **two-way**: the engine can tell *that* two versions differ, not *which* side changed.
1336One algorithm, one code path.
1338### 12.4 Node-level merge
1340Both versions are parsed into trees (embeds unresolved; each file merges on its own).
1342**Matching** nodes across versions, recursively from the root:
13431. by block id for embeds (exact);
13442. else by exact title among unmatched siblings under the matched parent;
13453. anything left is an insertion.
1347A retitled node therefore appears twice after a merge — once per title. Two-way merging
1348never deletes, so this is the safe failure; delete the copy you don't want.
1350**Per matched node**, for each of title, checkbox state, text (all its text children, with
1351their positions among its child nodes, as one field), and — for the file's block — each
1352frontmatter key independently: `O == T → take O`, otherwise **conflict**. A conflict pair is raised for every field that differs, even when
1353only one side touched it; the cost of having no per-device state is paid here, in
1354resolution clicks, never in lost text.
1356**Children** are merged as sequences of matched nodes: nodes present on one side only are
1357insertions, placed relative to their matched neighbours and clamped by the ordering rule
1358(§3.1). `O`'s text children are emitted where `O` has them. Nothing is ever treated as
1359deleted.
1361**Conflict output**: the `O` version stays in place. The `T` version is written as a new
1362block `<prefix>~<name>.md` (a fresh id; the name is `O`'s name) with frontmatter
1363`conflict: "<device> <timestamp>"` (plus, for a block conflict, `T`'s own properties),
1364and an embed to it is inserted as the next sibling of `O`, in the form matching `O`'s
1365spelling (§4.7) — for a section, a heading embed after its whole subtree. The conflict block
1366is therefore always the node immediately after the one it conflicts with; no other link
1367between them is stored. No text is ever lost: every node from `O` and `T` appears in the
1368result exactly once.
1370The result is written atomically to `X.md` and the conflict file goes to trash. The
1371algorithm is deterministic and idempotent.
1373### 12.5 Resolution
1375The conflict view (§10.7) lists every `conflict:` block, in outline order, each shown
1376against the sibling before it, and the status line counts them. Resolving a pair:
1378- **keep ours** — delete the conflict block and its embed (to trash);
1379- **keep theirs** — replace ours' title and children (text and nodes) with the conflict block's,
1380 and its frontmatter with the conflict block's minus `id` and `conflict`; then delete
1381 the conflict block and its embed (to trash);
1382- **keep both** — drop the `conflict` key; the block stays where it is as an ordinary
1383 sibling.
1385Unresolved pairs are ordinary blocks; they sync to every device and are visible in any
1386editor, and can be lived with indefinitely. The app marks each copy `⚠` and folds it,
1387leaves its tasks out of the counts, and offers it as no *Move to…* destination; `>` and a
1388drop put nothing under it (§10.1, §10.3). No verb puts a node between a copy and the node it
1389follows, which the copy would then pair with, nor takes one away from the other: `J`/`K`,
1390*Move to…*, `za`, `>`, `<` and a drop move the two as one, from either side, and into
1391neither; `d` on the node deletes its copies with it, and on a copy that copy alone, as
1392keep ours does; *clear done* keeps a done node whose copy it keeps; `~` is refused on
1393either side until the pair is resolved. A node added, pasted, dropped or outdented between
1394them goes after the copy.
1396### 12.6 History (outside the app)
1398The app keeps no history: per-node timestamps, "last modified", and completion dates of
1399inline tasks are simply not recorded anywhere (§3.5, §7, §8.1). If you want history, turn
1400on staggered file versioning on an always-on Syncthing node — the app neither needs it nor
1401notices it.
1403---
1405## 13. CLI
1407All commands take `--vault PATH` (default: `$FOLD_VAULT`, else the nearest ancestor of
1408`$PWD` containing `root.md`, else `~/fold`).
1410| Command | Purpose |
1411|---|---|
1412| `fold [--keys normal\|vim\|helix]` | open the TUI; creates `root.md` if the directory is empty (§4.1.1); `--keys` picks the editor's keymap (§10.6) |
1413| `fold capture [TEXT] [--to TARGET] [--task]` | append to the inbox (stdin if no TEXT) |
1414| `fold check [--fix]` | diagnostics (§15.7); `--fix` rewrites the vault in canonical form (§4.2) and repairs filenames (§6.4) |
1415| `fold merge [--dry-run]` | process sync-conflict files non-interactively; list leftovers |
1416| `fold trash list \| restore ID` | trash management |
1418That is the whole CLI: the entry points a shell, a hotkey, a cron job on the homelab, or a
1419recovery session needs. Everything else is a TUI verb.
1421---
1423## 14. Settings
1425There are no vault settings. Everything that could be one is either fixed behaviour or
1426content: the inbox is the section titled `Inbox` (§7);
1427filenames are an id prefix plus the title; names keep Unicode; ids are four words in
1428frontmatter (§6.4); making blocks
1429are property-triggered or manual only; `done:` records a date; conflict copies are
1430blocks marked `conflict:`. Each was a knob nobody needs to agree on with a file. Two vaults that
1431contain the same Markdown behave identically.
1433There are no device settings either. The app reads no file outside the vault but the view it
1434remembers (§10.1). What other tools keep in configuration is:
1436- the vault: `--vault PATH`, else `$FOLD_VAULT`, else the nearest ancestor of `$PWD`
1437 containing `root.md`, else `~/fold`;
1438- the trash location: `$XDG_STATE_HOME/fold/trash/` (§11.5);
1439- the editor's keymap: `--keys`, else `$FOLD_KEYS` (`normal`, `vim`, `helix`), else
1440 `normal` (§10.6);
1441- everything visual: fixed, or a toggle the view remembers per vault (`zp`, `zw`, `zd`,
1442 §10.1), or one for the session (`zr`).
1444The only things the app keeps outside the vault are the trash and the remembered view,
1445both on the device and never synced.
1447---
1449## 15. Architecture (Rust)
1451### 15.1 Workspace
1453```
1454crates/
1455 fold-core/ parsing, tree, render/splice, index, store, merge, op log — no TUI deps
1456 fold-tui/ ratatui application
1457 fold-cli/ clap binary; depends on both
1458 fold-ffi/ a session over fold-core for other languages, through UniFFI
1459android/ the Android app, over fold-ffi
1460```
1462`fold-core` is the API every front end uses; `fold-ffi` wraps it in a session that holds
1463what the TUI holds between keys, for the Android app or a Helix extension.
1465### 15.2 Core types (sketch)
1467```rust
1468pub struct NodeId(u32); // arena index, session-stable
1469pub enum Kind { Root, Section, Item } // Section / Item are spellings (§3.1)
1470pub enum TaskState { Open, Done }
1472pub struct Node {
1473 kind: Kind,
1474 title: String,
1475 task: Option<TaskState>, // any node, block or not: the checkbox on its title line
1476 content: Vec<Content>, // ordered children: text runs and nodes (§3.1)
1477 parent: Option<NodeId>,
1478 file: FileId,
1479 span: Span, // bytes in `file`: title line through subtree
1480 doc: Option<Block>, // Some if this node is the root of a file
1483pub struct Block {
1484 id: Option<Id>, // None only for root.md
1485 name: String, // slug of the title when made (or at the last `check --fix`)
1486 path: RelPath, // e.g. "racfer~order-new-switch.md"
1487 props: IndexMap<String, String>, // top-level `key: value` lines, order-preserving; includes `id`
1488 frontmatter_raw: String, // verbatim, for lossless rewrite
1489 edge_span: Span, // the embed line in the parent file
1492pub enum Content { Text(Span), Node(NodeId) } // `(Text | Item)* Section*` (§3.1)
1494pub struct Span { start: usize, end: usize }
1496pub struct Tree { nodes: Vec<Node>, root: NodeId, files: Vec<FileState> }
1497pub struct Cursor { path: Vec<NodeId> } // zipper-style focus; all outline verbs are cursor ops
1498pub enum NodeKey { // survives re-parses (§3.4)
1499 Root,
1500 Id(Id),
1501 Path { block: Option<Id>, steps: Vec<(String, usize)> }, // title + ordinal among same-titled siblings
1503```
1505Levels and indents are computed from ancestry, never stored.
1507### 15.3 Parser strategy
1509Hand-written, line-oriented, fence-aware. The structural grammar (§4.3) is regular per line
1510(headings at any indent and any depth are title lines);
1511bodies are opaque; frontmatter is a delimited block at byte 0 scanned line by line for
1512top-level `key: value` pairs — not a YAML parser, since nothing is typed and unknown lines
1513are never interpreted. This is deliberately not a CommonMark parser: the app needs
1514exact spans, lossless round-tripping, and a small canonical subset — not a full AST.
1515`pulldown-cmark` may be used later for inline styling in the reading pane only.
1517Reading tolerance (setext headings, `*` bullets, tabs, `[X]`, `[-]`) is handled in the lexer and
1518recorded per node as "non-canonical", which `fold check` reports and touching rewrites.
1520### 15.4 Dependencies
1522`ratatui`, `crossterm`, `notify` (watcher), `signal-hook` (§10.6),
1523`tree-sitter` + `tree-sitter-highlight` and the grammar crates of §10.9 (code highlighting), `jiff`
1524(today's date), `clap`, `indexmap`, `similar` (line diff, to find a block's span after an
1525outside change, §5.2), `blake3`, `unicode-normalization`, `unicode-width`, `directories` (XDG),
1526`getrandom` (64-bit ids; the `@p` syllable tables are a 512-entry constant in `fold-core`),
1527`uniffi` (`fold-ffi`), `tempfile`, `assert_cmd` + `predicates` (tests). The built-in editor
1528(§10.6) and the filter's fuzzy match (§10.5) are fold's own.
1530### 15.5 Performance targets
1532| Operation | Target |
1533|---|---|
1534| Parse a 5 MB `root.md` (~20k nodes) | < 50 ms |
1535| Re-parse after a single-node write | < 10 ms |
1536| Index build, 20k nodes, 2k blocks | < 200 ms |
1537| Fuzzy filter keystroke, 20k titles | < 5 ms |
1538| Filter box, 20k nodes, per keystroke | < 20 ms |
1539| Frame render | < 8 ms |
1540| Startup, 5 MB vault, no cache | < 300 ms |
1542### 15.6 Testing
1544The whole design rests on a few laws, and the tests check them on examples; property-based
1545tests over generated trees are the natural next step:
1547- `parse(render(t, 1, false))` equals `t` re-levelled, for all generated trees `t`.
1548- `render(parse(s), 1, false) == s` for all canonical blocks `s`, frontmatter included.
1549- `canonicalize` is idempotent.
1550- Every verb (`J`/`K`, `>`/`<`, `~`, `p`/`P`, refile, capture, `n`/`N`, make block, merge)
1551 leaves every node's children satisfying `(text | item)* section*`, keeps every other node's
1552 parent, and moves no text child it was not asked to move.
1553- `splice(node, render(node, 1, true))` is a no-op on disk, for any node.
1554- Every splice writes exactly one file; a save writes one splice per dirty block.
1555- `render(make_block(n), 1, false)` equals `render(n, 1, false)` plus the generated frontmatter,
1556 and `set key value` on any node yields a block whose file round-trips.
1557- Merge: `merge(O, O) == O`; `merge(O, T)` contains every node of `O` and `T` exactly
1558 once; merging is idempotent on its own output.
1560Plus `insta` snapshot tests for the TUI via `ratatui::backend::TestBackend`, and a corpus
1561of real-world Markdown (Obsidian, Logseq, FSNotes exports) that must parse without loss.
1563### 15.7 Diagnostics
1565`fold check` reports, file by file: non-canonical syntax, empty
1566titles, titles containing `/`, broken, duplicate or cyclic embeds, embeds with children in the
1567parent file, embeds whose form (bare or heading) does not match their block's spelling,
1568heading embeds whose level is not the level of their position, text directly after a child
1569node with no blank line, column-0 lines adopted by a block's root, malformed block files, invalid or duplicate `id` keys, ignored `.md` files,
1570filenames whose prefix or name no longer match their id or title, frontmatter outside
1571byte 0, `due` or `done` values that are not ISO dates, and unresolved conflict blocks.
1572The TUI
1573surfaces the same diagnostics in the status line for the current file.
1575---
1577## 16. Interoperability
1579The vault is Markdown-shaped but not Markdown; §4.2 lists the four things a standard renderer
1580gets wrong. What other tools see:
1582| Tool | What it sees | Notes |
1583|---|---|---|
1584| `rg`, `fd`, Helix | plain text | `rg '\[ \]'` is the open task list; `rg '^done: 2026-09'` the blocks finished this month; `rg '^due:'` the dated ones |
1585| Any phone editor | plain text | capture by appending a `- ` line to the inbox block's file, at any indent: a column-0 line is adopted by the block's root (§4.9); the app adds nothing to it |
1586| Obsidian, Logseq, FSNotes | mostly readable, structurally wrong | frontmatter as properties and `- [ ]` bullets render; `![[id]]` shows as a broken embed (a heading embed as a heading holding one), `## [ ]` as a literal heading, `#######` as a paragraph, indented headings as code. Fine for reading a file; do not edit structure there |
1588---
1590## 17. Non-goals for 1.0
1592Encryption; CRDT / operation-log sync;
1593a query language, saved views and `--json` output (the ```` ```query ```` fence is reserved); attachment management beyond ignoring `assets/`; full Markdown
1594rendering or WYSIWYG; plugins; task recurrence; spaced repetition; whiteboards; multiple
1595vaults open at once; general transclusion; an external `$EDITOR` handoff; properties on non-blocks; export or lowering
1596to standard Markdown (the four non-standard forms of §4.2 are the whole gap); a cancelled
1597task state; wiki-links between nodes and backlinks
1598(`[[…]]` is reserved, §4.6); rendering the format correctly in third-party editors.
1600---
1602## 18. Milestones
1604| # | Deliverable | Exit criterion |
1605|---|---|---|
1606| M0 | `fold-core`: parser incl. frontmatter, tree, render/splice, canonicalize, ids and names | round-trip laws pass under proptest; `fold check` works |
1607| M1 | TUI: outline + reading panes, zoom, fold, move, promote/demote, refile, filter box, subtree editing (`e`), undo | daily-drivable on a single `root.md` |
1608| M2 | Blocks and properties: `s`, property-triggered blocks, task blocks | a task can be given a due date in the editor and its file round-trips |
1609| M3 | Tasks: toggling on any node, `done:` stamping, dates, clear-done, filter box | replaces a TaskPaper file |
1610| M4 | Inbox and capture; watcher and reload | safe to edit in Helix and the TUI at once |
1611| M5 | Sync: conflict detection, two-way node-level merge, `conflict:` blocks, conflict view, `fold merge` | phone + laptop + homelab over Syncthing for two weeks without data loss |
1612| M6 | CLI polish, diagnostics, docs | 1.0 |
1614---
1616## 19. Decisions
16181. **Name.** `fold`: the binary, the crate prefix and the state directory.
16192. **Dated inbox, ISO titles, fixed.** Capture creates `## YYYY-MM-DD` under `Inbox`. There
1620 is no format choice and no way to turn it off except `--to`. Days append oldest-first; a
1621 long-lived inbox is a long section, which is what `s` (make block) and *clear done* are for.
16223. **Filenames are named once.** A title change does not rename the file: renames sync as
1623 delete-plus-create and race with edits on other devices. The id prefix identifies the
1624 file; the name is a hint that `fold check --fix` refreshes on request.
16254. **Id format.** Four-word `@p` names (64 bits): collision-free in practice, with three words
1626 of slack for the filename prefix to grow into. Pronounceable, typeable from memory, and
1627 self-validating against the syllable tables; nobody is expected to type all four.
16285. **Task sections.** Any node can be a task, so a project heading can be checked off. The
1629 derived count is still shown beside it; there is no roll-up in either direction (§8.3).
16306. **One spelling of task state.** The checkbox, for every node, so `rg '\[ \]'` finds every
1631 open task and making a block never converts anything. Frontmatter holds only what a
1632 checkbox cannot: `done:`.
16337. **Two task states.** Open and done. "Won't do" is a deleted line or a done line; a third
1634 state would cost a value in every table, a second date key and a key binding. `[-]` is
1635 read as done.
16368. **No un-block.** There is no inverse of making a block: it would have to discard the
1637 frontmatter, and a verb whose defining feature is discarding data is a footgun. The
1638 manual route (yank, paste, delete) makes the loss explicit.
16399. **No export.** The resolved render is what the reading pane shows; a standalone-Markdown
1640 exporter is future work with a known scope (§4.2's four forms).
164110. **No fuzzy matching in merge.** A node retitled on one device and edited on another
1642 comes out of a merge as two nodes. A fuzzy title match would need a tunable threshold and
1643 could pair the wrong nodes; a duplicate is cheaper to fix than a wrong pairing.
164411. **Two-way merge, no cache.** A per-device base copy would make sync merges three-way,
1645 but it would be the only per-device state (§11.3). Sync conflicts therefore produce a
1646 conflict pair for every differing field; the mitigation is structural — keep
1647 concurrently-edited things in separate files (§12.1), so conflicts are small and rare.
164812. **Text is edited as text, properties as a form.** One subtree edit in a text box covers
1649 titles, bodies and structure. Frontmatter is a storage format, and showing it would
1650 surface files, so the property editor is the one non-text edit. The built-in editor is a
1651 text box; if it wants to become a Markdown editor, stop.
165213. **Inbox by title.** Capture finds the inbox by the title `Inbox`. Renaming it means the
1653 next capture creates a new one. A hidden marker property would be the only setting.
165414. **No config file.** Presentation choices are fixed or session toggles; a config format, a
1655 lookup order and a `fold config` command are not worth it. A real per-device need is
1656 one environment variable: the editor's keymap is `$FOLD_KEYS` (§10.6), and a
1657 colour-blind palette, say, would be another.
165815. **Windows.** Slugs and atomic writes are Windows-safe by design; nothing else is tested.
165916. **No links or query language in 1.0.** Embeds prove the id machinery, and links can be
1660 built on it without changing the file format, which is why `[[…]]` is reserved rather
1661 than free. The likely query shape is "everything is a key" (`due:..today`, text is
1662 literal, `-` negates, `..` ranges); until then the filter box is the only search and the
1663 `query` fence is reserved.
166417. **No per-node timestamps.** One metadata mechanism is worth more than inline annotations
1665 such as `@done(…)`. An inline task that needs a date becomes a block; the day section is
1666 the only creation stamp anything else gets.
166718. **Flat vault.** The app never creates a directory and ignores any it finds. Hierarchy is
1668 embeds, and one place for files keeps prefixes unique vault-wide with no per-directory
1669 rule.
167019. **Prefix collisions.** Two blocks whose ids share a first word are `racfer~notes.md`
1671 and `racfer-wolsun~order-new-switch.md`, regardless of their titles; the prefix grows only
1672 as far as needed and never shrinks (§6.4). Uniqueness is judged on the prefix alone, so
1673 the prefix is a complete address and titles can change freely. Roughly one file in 256
1674 needs a second word from day one, which is cheaper than any rule that depends on titles;
1675 the full id is too long for a name, the bare slug too collision-prone across devices, and
1676 a numeric `-2` meaningless.
167720. **Ownership travels with lines.** Each editor line is tagged with its block, and blocks
1678 are saved as the cursor leaves them. That needs no re-matching of nodes after an edit,
1679 shows no seam between files, and still writes each file from its own text. An external
1680 editor cannot carry the tags, so there is none in 1.0 (§17).
168121. **Third-party editors are readers, not editors.** Once the format owns syntax, Obsidian
1682 and friends are not a safe place to restructure a vault. Editing a paragraph there is
1683 fine; moving headings is not. This is the price of §1 principle 2 and is accepted.