diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63240ef..c12d53b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: workspaces: ./src-tauri -> target # Named packages, NOT --workspace: that would pull in the Tauri app crate - # and with it WebKitGTK on Linux / WebView2 on Windows. These nine are + # and with it WebKitGTK on Linux / WebView2 on Windows. These ten are # split out (CLAUDE.md §4) precisely so their gates run without a webview. # # keystore's tests all run against its in-memory double. The real @@ -94,7 +94,10 @@ jobs: # # Windows matters here too: fsatomic is the §3.1 atomic-save gate and the # one crate doing real filesystem syscalls, where replace-over-existing - # differs between rename(2) and MoveFileEx. + # differs between rename(2) and MoveFileEx. fileops is the second such + # crate and needs Windows even more specifically: the names it rejects + # (reserved devices, trailing dots) and the case-only rename it must allow + # are *Windows* behaviours, and the Linux leg alone cannot see either. - name: Test logic crates run: > cargo test @@ -107,6 +110,7 @@ jobs: -p snapshots -p mergemd -p keystore + -p fileops # `--all` covers workspace *members*, and the vendored glib is excluded # from the workspace (§2) — so unlike clippy, this never touches upstream diff --git a/CHANGELOG.md b/CHANGELOG.md index 4406952..88a7ab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,28 @@ GitHub Release notes plus the commits that shipped in it. ## [Unreleased] ### Added +- **Manage your notes from the files pane.** New note, new folder, rename and delete, + from a right-click on any file or folder — or the `+` button beside the folder name. + Until now Toril could open and save notes but not *organize* them: renaming a note + meant leaving for the file manager and coming back. + + **Deleting moves the note to a `.trash` folder inside your workspace, and offers you + Undo.** Nothing is unlinked, so a delete is recoverable long after the message has + gone — and because it is recoverable, Toril does not interrupt you to confirm it. The + one thing trash cannot bring back is a buffer you never saved, so that is the one case + that stops and asks. + + **A rename takes the note's version history with it**, and moves any open tab — + including every tab inside a renamed folder — to the new path. Renaming does not + rewrite a single byte of the file. + + Names are checked before anything touches disk, against the rules Windows actually + enforces: no `< > : " / \ | ? *`, nothing ending in a space or a dot, and none of the + reserved device names (`CON`, `NUL`, `COM1`…) that appear to work and then behave like + hardware. Renaming `notes.md` to `Notes.md` — a change of case only — works. Nothing + can be created or renamed outside the folder you opened, and no operation will ever + overwrite a file that is already there. + - **Toril can update itself.** `v1.0.0` had no update path at all, so every copy was stranded on the version it was installed with — the only way forward was to notice a new release and download the installer by hand. Toril now checks for a newer build diff --git a/CLAUDE.md b/CLAUDE.md index ca6c3a0..8dcf893 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,16 @@ The **QoL half of the same branch** has also landed: editor zoom, open-links-in- drag-and-drop open, a recent-files list in File → Open Recent, and a real first-run welcome note distinct from the empty state. See §5 (Quality-of-life batch). +**Sidebar file operations — `feat/sidebar-file-ops` (ROADMAP Movement II.12).** New note, +new folder, rename and delete, from a context menu in the files pane. This closes two +pieces of already-built, already-tested Rust that had **no caller at all**: `trashbin` +(shipped in v1.0.0 with no UI) and `snapshots::rekey` (written for exactly this rename). +New crate `crates/fileops` holds the rules; `commands/entries.rs` wraps it; the UX is +`src/ui/sidebar.ts` on a new `src/ui/contextmenu.ts`. Details and the rename-ordering +argument are in §5 (Sidebar file operations). Still open from the branch: drag-to-move in +the tree, multi-select, and a trash browser (`list_trash` has a command and a wrapper but +no UI, so Undo reaches only the most recent delete). + **Next:** finish Phase 4 — remaining is Azure Trusted Signing once an account exists, and on-device verification. A backlog of further QoL features is in §13. The **forward plan beyond Phase 4** — turning the editor into a notes *system* (search, links, version @@ -151,8 +161,8 @@ pnpm tauri build # production .exe + installer (Windows; see §9) pnpm test # vitest — round-trip + toolbar + theme + export + tabs + security (jsdom) pnpm typecheck # tsc --noEmit (TS strict) pnpm build # tsc + vite build (frontend only) -# logic crates — the same nine CI runs (plain `cargo test` also builds the app crate) -cd src-tauri && cargo test -p fsatomic -p vaultscan -p mdhtml -p mdrtf -p imgasset -p trashbin -p snapshots -p mergemd -p keystore +# logic crates — the same ten CI runs (plain `cargo test` also builds the app crate) +cd src-tauri && cargo test -p fsatomic -p vaultscan -p mdhtml -p mdrtf -p imgasset -p trashbin -p snapshots -p mergemd -p keystore -p fileops cd src-tauri && cargo fmt --all && cargo clippy # clean before commit (§10) ``` @@ -277,7 +287,8 @@ toril/ │ │ ├── html-serializer.ts # the ONE HTML <-> doc converter (§3.2/§3.3) │ │ └── html-constructs.ts # richer HTML-only schema: callout/details/dl/mark/sub/sup (§6) │ ├── ui/ -│ │ ├── sidebar.ts # file tree +│ │ ├── sidebar.ts # file tree + file operations UX (§5, ROADMAP II.12) +│ │ ├── contextmenu.ts # the one in-app context menu (DOM, not native — §8) │ │ ├── panes.ts # PURE pane state: visibility, widths, rail tab (§13) │ │ ├── rail.ts # the single tabbed right rail (outline | history) │ │ ├── resizer.ts # drag-to-resize: pure geometry + thin DOM binding @@ -310,13 +321,15 @@ toril/ │ ├── trashbin/ # soft-delete to workspace .trash/ + restore (§3) │ ├── snapshots/ # content-addressed local version history (§3, ROADMAP I.3) │ ├── mergemd/ # line-based 3-way merge + conflict filenames (§3, ROADMAP I.4) - │ └── keystore/ # OS-keychain API key storage (§3, ROADMAP IV.20) + │ ├── keystore/ # OS-keychain API key storage (§3, ROADMAP IV.20) + │ └── fileops/ # create/rename: name rules, containment, no clobber (ROADMAP II.12) └── src/ ├── main.rs # bin entry → lib::run() ├── lib.rs # Tauri builder + menu + command registration ├── menu.rs # native app menu → `menu` events (§8) ├── commands/ │ ├── files.rs # open / save (ATOMIC) / save_as + │ ├── entries.rs # create note/folder, rename (+ history rekey) — ROADMAP II.12 │ ├── workspace.rs # open folder, list tree, watch (notify crate) │ ├── export.rs # markdown_to_html + export_html; export_rtf (all-Rust) │ ├── images.rs # save_clipboard_image (imgasset) @@ -350,6 +363,10 @@ frontend never touches the filesystem directly; it asks via `invoke()`. | `save_recovery` | `entries` | `()` | **atomic** write of `recovery.json` in the app config dir — crash-recovery journal (§3) | | `load_recovery` | — | `RecoveryEntry[]` | empty on missing/corrupt (never bricks startup) | | `clear_recovery` | — | `()` | delete `recovery.json` — the clean-shutdown sentinel | +| `create_note` | `vault_root, dir, name` | `path` | Create an **empty** note in `dir` (`crates/fileops`). `name` may omit the extension (`.md` added). `create_new`, so the existence check and the creation are one operation — two racing creates cannot both win | +| `create_folder` | `vault_root, parent, name` | `path` | `create_dir`, not `create_dir_all`: an existing folder is an error, not a silent success | +| `suggest_note_name` | `vault_root, dir, stem` | `name` | `Untitled.md`, else `Untitled 2.md`… A **suggestion** for the inline field only; `create_note` still refuses a collision. Takes `vault_root` although it only reads: it answers "does this file exist?" for any path handed to it, and the webview is untrusted (§3.3) | +| `rename_entry` | `vault_root, path, new_name` | `path` | Rename within the same parent, refusing to clobber a *different* entry (a **case-only** rename is allowed — resolved via `canonicalize`, since on a case-insensitive filesystem the destination "exists" as the source itself). Carries version history through `snapshots::rekey` — for a folder, every note in the subtree — **best-effort and additive**: the rename already happened, and history failing to follow must not report it as a failure | | `move_to_trash` | `vault_root, path` | `TrashEntry` | soft-delete into workspace `.trash/` via `trashbin` — **atomic** move (§3) | | `list_trash` | `vault_root` | `TrashEntry[]` | newest first; empty when no `.trash/` | | `restore_from_trash` | `vault_root, id` | `path` | restore to original path; errors **without clobbering** an existing file | @@ -402,9 +419,34 @@ frontend never touches the filesystem directly; it asks via `invoke()`. > `manifest.json`) rather than `rm`; restore reads the manifest and atomically renames > it back, refusing to clobber a file that reappeared at the path. `.trash/` starts with > `.`, so `vaultscan` already hides it from the sidebar (§1) and Obsidian hides it too. -> Backed by `crates/trashbin`; commands are not yet called by any UI (the sidebar file-ops -> branch wires them). +> Backed by `crates/trashbin`, and wired to the sidebar as of Movement II.12. +> +> **Sidebar file operations (Movement II.12).** `crates/fileops` holds the rules — +> name validation, vault containment, refusing to clobber — and `commands/entries.rs` +> is a thin wrapper plus the history rekey. The UX is `src/ui/sidebar.ts` (context menu, +> inline name field) on `src/ui/contextmenu.ts`. **The menu is a DOM menu, not a native +> one**, and deliberately: a native popup cannot be driven by the headless gates, and +> native dialogs are the one thing documented to hang the app on the Linux dev box. The +> single native dialog left is the unsaved-changes confirm on delete (`confirmDiscard`), +> which is the close guard's prompt and the same judgement call. +> +> **Delete offers Undo instead of asking first.** The move is into `.trash/`, so it is +> reversible by construction; a confirmation would be friction in front of a reversible +> act. `restore_from_trash` — dead code until this branch — is what the Undo calls. What +> trash cannot restore is an **unsaved buffer**, so that case does stop and ask. > +> **A rename is the §3 surface of this feature, and the ordering in `doRenameEntry` is +> the fix.** The watcher reports a rename as delete-then-create, which is exactly the +> shape `removedOnDisk` exists to catch — left alone, an open tab would decide its file +> had vanished and offer to recreate it, resurrecting the old note beside its new name. +> So, in one synchronous block after the rename resolves: re-point every affected tab +> (including tabs *under* a renamed folder), bump `removalEpoch` for each old path (a +> `reconcile` may be in flight against it and would apply a `missing` verdict to a tab +> that has moved), and clear `removedOnDisk` in case the delete event already landed. +> `base` is deliberately **not** reset — a rename changes no bytes, so the merge base is +> still exactly right and re-reading would only invite a race. +> + > **Version history.** Every save records a content-addressed snapshot (`crates/snapshots`): > per-note dir under `/history//` — a `manifest.json` + gzip, > sha256-addressed blobs. It lives **outside the vault** (like recovery/session, §1) so it @@ -582,6 +624,18 @@ Phases 0–3 are complete and Phase 4 (polish) is in progress; the shipped detai never drops a line. The wire protocol's fifth outcome, `missing`, is produced one layer up in `src-tauri/src/commands/sync.rs` (an `io::ErrorKind::NotFound` on the read, before `mergemd` is even called) — that file has **no tests of its own**, so `missing` is exercised on-device only. +- **File operations:** `cargo test -p fileops` — the name rules (the Windows-forbidden character + set, control characters, trailing dot/space, reserved device names *with* an extension, and the + near-misses that must still be **accepted**: `console.md`, `com10.md`), containment against a + traversal dressed up as a subdirectory, every create/rename refusing to clobber while a + **case-only** rename still succeeds, and that a returned path keeps the *caller's* spelling — + the regression that pins it: containment is checked with `canonicalize`, which on Windows + returns `\\?\C:\…`, and building the result from that hands back a path matching neither the + sidebar tree, nor an open tab, nor the note's history key. Plus `tests/sidebar.test.ts` (the menu + offers only what is wired, a rejected name keeps the field open with the message, blur cancels + rather than commits, and a background refresh cannot delete what is being typed) and + `tests/contextmenu.test.ts` (one menu at a time; dismissal actually removes its document + listeners; the menu closes *before* its handler runs, since handlers take focus). - **External-change policy:** `tests/sync.test.ts` — `decideAction`'s outcome→action mapping is total and fails closed, HTML never auto-merges, and `selectSavable` excludes a diverged tab from every bulk write path (§5). @@ -638,9 +692,11 @@ Phases 0–3 are complete and Phase 4 (polish) is in progress; the shipped detai **CI runs these automatically** on every pull request and on pushes to `main` (`.github/workflows/ci.yml`): `pnpm typecheck` + `pnpm test` + `pnpm build`, and `cargo test` over the -nine logic crates — each on **Ubuntu and Windows**, plus `cargo fmt --all --check` on Ubuntu. The -Windows leg is not ceremony: `pnpm install --frozen-lockfile` is what applies the Milkdown patch, and -`fsatomic` is the §3.1 gate whose replace-over-existing semantics differ from POSIX there. +ten logic crates — each on **Ubuntu and Windows**, plus `cargo fmt --all --check` on Ubuntu. The +Windows leg is not ceremony: `pnpm install --frozen-lockfile` is what applies the Milkdown patch, +`fsatomic` is the §3.1 gate whose replace-over-existing semantics differ from POSIX there, and +`fileops` encodes *Windows* naming rules (reserved devices, trailing dots, case-only renames) that +the Linux leg alone cannot see. **What CI cannot cover — still yours to run:** interactive GUI flows (`pnpm tauri dev` — dialogs, menus, the reload prompt), macOS, the Tauri app crate, and `cargo clippy` (§10; excluded from CI @@ -826,6 +882,8 @@ Keep the project's rules: testable logic in `crates/*` or pure TS helpers, all d **Medium (more UI / a new command, but high value):** - **Global workspace search ("find in files")** — a Rust command scanning the vault (sibling to `vaultscan`, keeping scan logic unit-testable) + a results panel. Distinct from in-document Find. -- **Sidebar file operations** — new / rename / delete / new-folder via context menu, backed by new - **atomic** Rust commands; mind the watcher interplay. -- **Document outline / TOC panel** — list headings from the doc, click to scroll. +- ~~**Sidebar file operations**~~ — *shipped* (ROADMAP Movement II.12). Still open from that + branch: **drag-to-move** within the tree, **multi-select**, and a **trash browser** + (`list_trash` has a command and an `ipc.ts` wrapper but no UI — Undo is currently the only + way back, and it only reaches the most recent delete). +- ~~**Document outline / TOC panel**~~ — *shipped* (Movement II.11). diff --git a/ROADMAP.md b/ROADMAP.md index f5bddb3..9ef1c6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,7 +46,7 @@ It is a fine **editor**. It is not yet a **notes system** — no global search, quick switcher, no links, no tags, no version history, no sync conflict handling, no AI. That gap is this roadmap. -> **Status (2026-08-17).** Shipped through **`v1.0.0`** (see `CHANGELOG.md`). +> **Status (2026-08-18).** Shipped through **`v1.0.0`** (see `CHANGELOG.md`). > **Movement I, branches 1–4 are complete** (autosave + crash-recovery journal; > safe-delete-to-trash; local version history; sync coexistence — 3-way merge, conflict > banner, parked conflict copies). **Branch 5 has landed** on `feat/release-readiness` — @@ -63,11 +63,20 @@ no AI. That gap is this roadmap. > after it, so the next feature could not have reached anyone. §7's *trust before reach* > is what settles the order. Read the pointer below as the ordering, not the ladder. > -> **▶ Pick up at Movement II, branch 6 — `feat/vault-search`.** (Branch 5 is done bar +> **Branch 12 (sidebar file operations) has also landed** — PR #41, stacked on branch 5's +> PR #40 — taken ahead of 6 for the same kind of reason 10 was: it wired two crates — +> `trashbin` and `snapshots::rekey` — that had shipped with **no caller at all**, and until +> it landed a note could be opened and saved but not renamed or deleted without leaving the +> app. **Branch 12b (chrome rework) is ticked too**; it shipped on `main` 2026-08-12 and this +> document had simply never recorded it. +> +> **▶ In progress: Movement II, branch 6 — `feat/vault-search`.** (Branch 5 is done bar > Azure signing, which is blocked on an account, not on code.) > Search is the largest remaining functional gap, and branch 7 > (command palette) depends on it. Vet `tantivy` per §2 at -> adoption — it would be the project's largest new dependency. Branch 4's spec lived +> adoption — it would be the project's largest new dependency, and worth weighing +> against a hand-rolled inverted index the way the snapshot store was weighed +> against `gix` (§8). Branch 4's spec lived > **on its own branch**, not on `main`: > `docs/superpowers/specs/2026-07-24-sync-coexistence-design.md`; branch 10's is on > `main` at `docs/superpowers/specs/2026-08-17-frontmatter-properties-design.md`. @@ -75,9 +84,10 @@ no AI. That gap is this roadmap. > *Landed since, outside the movement ladder:* a serializer-normalization precursor > (canonical markdown now matches Obsidian — `-` bullets, `---` rules, tight lists > preserved), which exists to keep branch 4's 3-way merge from drowning in -> reformatting noise; the chrome/layout rework (CLAUDE.md §12b); a fix for programmatic -> loads marking every document dirty; CI running the headless gates on Ubuntu and -> Windows for every pull request; and the GitHub community-standards docs. +> reformatting noise; a fix for programmatic loads marking every document dirty; CI running +> the headless gates on Ubuntu and Windows for every pull request; and the GitHub +> community-standards docs. (The chrome/layout rework — CLAUDE.md §12b — used to be listed +> here as outside the ladder. It is not: it is **branch 12b**, and it is ticked below.) > > *Version note, and read it before leaning on the number:* `v1.0.0` says the editor and > its data-safety floor are ready to depend on — **not** that this roadmap is finished. @@ -239,8 +249,12 @@ nice in a synced folder. This movement is also the prerequisite for the AI wedge `menu.rs`, `main.ts`. - *Gate:* `tests/update.test.ts` for the policy; §D of `docs/ON-DEVICE-VERIFICATION.md` for everything downstream of the network, which no headless gate can reach. - - [ ] **⬢ RELEASE `v0.2.0-alpha`** — *"Safe to live in."* First build you can hand to - someone without an asterisk on their data. + - [~] **⬢ RELEASE `v0.2.0-alpha`** — *"Safe to live in."* **Overtaken by the `v1.0.0` + tag**, which shipped the data-safety floor under a number this ladder never planned + for. Kept as the record of what the release point *meant* — the first build you can + hand to someone without an asterisk on their data — not as a version still to be cut. + The floor itself is complete; only Azure Trusted Signing is outstanding, and it is + blocked on provisioning, not on code. --- @@ -303,11 +317,27 @@ editor something you keep using rather than something you tried. frontend (was §13 backlog). - *Touches:* `src/ui/outline.ts`. *Gate:* `tests/outline.test.ts`. -- [ ] **12. `feat/sidebar-file-ops`** — new / rename / delete / new-folder via context menu, - backed by **atomic** Rust commands; delete routes through `trashbin`. - - *Touches:* `commands/files.rs` (new atomic ops + contract rows), `src/ui/sidebar.ts`; - mind the watcher interplay. - - *Gate:* extend `fsatomic` / a new ops suite. +- [x] **12. `feat/sidebar-file-ops`** — *(shipped)* new / rename / delete / new-folder via a + context menu; delete routes through `trashbin` and offers **Undo** rather than a + confirmation, because a move into `.trash/` is reversible by construction. Wires two + crates that had no caller: `trashbin` and `snapshots::rekey`. + - *New crate:* `crates/fileops` — name validation (the Windows rules, applied on every + platform, since a vault is a folder that gets synced), vault containment, and + create/rename that refuse to clobber. Deliberately returns paths in the **caller's** + spelling: `canonicalize` is used to check containment and never to build a result, + because on Windows it yields `\\?\C:\…`, which matches neither the sidebar tree nor an + open tab nor a note's history key. + - *Built:* `commands/entries.rs` (thin wrapper + the history rekey, best-effort so it can + never fail a rename that already happened), `src/ui/contextmenu.ts`, file-ops UX in + `src/ui/sidebar.ts`. + - *§3:* the rename ordering in `main.ts`'s `doRenameEntry` — the watcher reports a rename + as delete-then-create, so tabs are re-pointed, `removalEpoch` bumped and `removedOnDisk` + cleared in one synchronous block, or an open tab would offer to recreate the old note + beside its new name. + - *Gate:* `cargo test -p fileops` + `tests/sidebar.test.ts` + `tests/contextmenu.test.ts`. + - *Open:* drag-to-move in the tree, multi-select, and a trash browser (`list_trash` is + wired to no UI, so Undo reaches only the most recent delete). On-device verification in + `docs/ON-DEVICE-VERIFICATION.md` §E. - [ ] **⬢ RELEASE `v0.5.0-beta.1`** — *"A real notes system."* First **beta**. --- @@ -320,7 +350,7 @@ between an editor that works and one that is pleasant to spend hours in. **Branches** -- [ ] **12b. `feat/chrome-ux`** — the UI shell around the editor: a **single tabbed right +- [x] **12b. `feat/chrome-ux`** — *(shipped 2026-08-12)* the UI shell around the editor: a **single tabbed right rail** (outline and history become alternatives, not two rails), pane collapse/expand that actually animates, drag-to-resize with persisted widths, pointer-adaptive touch targets, a real design-token layer, and **real menu accelerators**. Taken out of @@ -499,6 +529,7 @@ All follow the `crates/*` pattern: webview-free, unit-tested, healthy pure-Rust | `trashbin` | Soft-delete + restore (atomic) | — | `feat/safe-delete-trash` | | `snapshots` | Content-addressed local version history | hand-rolled (`sha2` + `flate2`); see §8 | `feat/local-version-history` | | `mergemd` | 3-way markdown merge + conflict files | `similar` | `feat/sync-coexistence` | +| `fileops` | Create/rename: name rules, containment, no clobber | — | `feat/sidebar-file-ops` | | `vaultsearch` | Incremental full-text vault index | `tantivy` | `feat/vault-search` | | `linkgraph` | `[[link]]`/`#tag` parse + backlink index | hand-rolled / `pulldown-cmark` | `feat/wikilinks-backlinks` | | `highlight` | Code → highlighted spans | `syntect` (`fancy-regex`) | `feat/code-highlighting` | diff --git a/dev-harness.html b/dev-harness.html index 672ba81..2799bf1 100644 --- a/dev-harness.html +++ b/dev-harness.html @@ -96,6 +96,80 @@ { hash: "c9d0e1f2", saved_at: Date.now() - 86_400_000 * 3, bytes: 201 }, ]; + // ---- in-memory vault mutations (ROADMAP II.12) -------------------------- + // + // Enough of `fileops` / `trashbin` to drive the sidebar's file operations + // here: create, rename, delete-to-trash and restore against TREE + FILES. + // Deliberately *not* a reimplementation of the rules — the real ones are + // unit-tested in Rust. What this stub owes the harness is the small set of + // refusals the UI has to render (a taken name, a reserved device name), so + // the error path can be seen without a backend. + const TRASH = []; + let trashSeq = 0; + + /** The children array a path belongs in, or null if the folder is unknown. */ + function listFor(dir) { + if (dir === "/vault") return TREE; + let found = null; + const walk = (nodes) => { + for (const n of nodes) { + if (n.is_dir && n.path === dir) found = n.children; + else if (n.is_dir) walk(n.children); + } + }; + walk(TREE); + return found; + } + + function findNode(path) { + let hit = null; + const walk = (nodes, parent) => { + for (const n of nodes) { + if (n.path === path) hit = { node: n, siblings: nodes }; + else if (n.is_dir) walk(n.children, n); + } + }; + walk(TREE, null); + return hit; + } + + // vaultscan's order: directories first, then case-insensitive by name. + function insert(list, node) { + list.push(node); + list.sort((a, b) => + a.is_dir === b.is_dir + ? a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + : a.is_dir ? -1 : 1, + ); + } + + const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i; + function checkName(name) { + if (!name.trim()) throw new Error("Enter a name."); + if (/[<>:"/\\|?*]/.test(name)) throw new Error(`A name cannot contain ${name.match(/[<>:"/\\|?*]/)[0]}`); + if (/[. ]$/.test(name)) throw new Error("A name cannot end with a space or a dot."); + if (RESERVED.test(name)) throw new Error(`"${name.split(".")[0]}" is a reserved name on Windows.`); + } + + function withExtension(name) { + return /\.(md|markdown|html?)$/i.test(name) ? name : `${name}.md`; + } + + /** + * Move a subtree's paths (and any FILES entries) from one root to another. + * `from` stays the *original* root throughout, so a child's path — still + * un-rewritten when it is visited — slices correctly against it. + */ + function repath(node, from, to) { + const oldPath = node.path; + node.path = to + oldPath.slice(from.length); + if (!node.is_dir && oldPath in FILES) { + FILES[node.path] = FILES[oldPath]; + delete FILES[oldPath]; + } + for (const child of node.children) repath(child, from, to); + } + // ---- fake Tauri bridge -------------------------------------------------- const callbacks = new Map(); let nextCallbackId = 1; @@ -146,7 +220,74 @@ list_history: () => HISTORY, read_snapshot: ({ path }) => FILES[path] ?? "", restore_snapshot: () => null, - list_trash: () => [], + create_note: ({ dir, name }) => { + const fileName = withExtension(name); + checkName(fileName); + const list = listFor(dir); + if (!list) throw new Error("That location is outside the open folder."); + const path = `${dir}/${fileName}`; + if (findNode(path)) throw new Error(`"${fileName}" already exists`); + insert(list, { name: fileName, path, is_dir: false, children: [] }); + FILES[path] = ""; + return path; + }, + create_folder: ({ parent, name }) => { + checkName(name); + const list = listFor(parent); + if (!list) throw new Error("That location is outside the open folder."); + const path = `${parent}/${name}`; + if (findNode(path)) throw new Error(`"${name}" already exists`); + insert(list, { name, path, is_dir: true, children: [] }); + return path; + }, + suggest_note_name: ({ dir, stem }) => { // vaultRoot ignored: one vault here + + for (let n = 1; n < 100; n++) { + const candidate = n === 1 ? `${stem}.md` : `${stem} ${n}.md`; + if (!findNode(`${dir}/${candidate}`)) return candidate; + } + return `${stem}.md`; + }, + rename_entry: ({ path, newName }) => { + checkName(newName); + const hit = findNode(path); + if (!hit) throw new Error("no such entry"); + const parent = path.slice(0, path.lastIndexOf("/")); + const target = `${parent}/${newName}`; + if (target !== path && findNode(target)) throw new Error(`"${newName}" already exists`); + repath(hit.node, path, target); + hit.node.name = newName; + insert(hit.siblings, hit.siblings.splice(hit.siblings.indexOf(hit.node), 1)[0]); + return target; + }, + move_to_trash: ({ path }) => { + const hit = findNode(path); + if (!hit) throw new Error("no such entry"); + hit.siblings.splice(hit.siblings.indexOf(hit.node), 1); + const entry = { + id: `harness-${trashSeq++}`, + original_path: path, + name: hit.node.name, + deleted_at: Date.now(), + }; + TRASH.push({ entry, node: hit.node }); + return entry; + }, + restore_from_trash: ({ id }) => { + const idx = TRASH.findIndex((t) => t.entry.id === id); + if (idx === -1) throw new Error("no such trash entry"); + const { entry, node } = TRASH[idx]; + const parent = entry.original_path.slice(0, entry.original_path.lastIndexOf("/")); + const list = listFor(parent); + if (!list) throw new Error("the original folder is gone"); + // The real `restore` refuses rather than clobbers; so does this, since + // that refusal is a message the UI has to render. + if (findNode(entry.original_path)) throw new Error("something is already there"); + insert(list, node); + TRASH.splice(idx, 1); + return entry.original_path; + }, + list_trash: () => TRASH.map((t) => t.entry), list_api_keys: () => [ { provider: "anthropic", configured: false }, { provider: "openai", configured: false }, diff --git a/docs/ON-DEVICE-VERIFICATION.md b/docs/ON-DEVICE-VERIFICATION.md index 8bae349..faf452c 100644 --- a/docs/ON-DEVICE-VERIFICATION.md +++ b/docs/ON-DEVICE-VERIFICATION.md @@ -1,7 +1,7 @@ # On-Device Verification CI covers the headless gates: `pnpm typecheck` / `test` / `build` and `cargo test` over -the nine logic crates, on Ubuntu and Windows (CLAUDE.md §8). A green PR means those +the ten logic crates, on Ubuntu and Windows (CLAUDE.md §8). A green PR means those passed — **not** that the app was driven. This file is the standing list of what a green PR cannot tell you: interactive flows @@ -214,6 +214,47 @@ that a signed artifact downloads, verifies and replaces a running binary. appears. Ask via Help → Check for Updates: it says it could not check. That asymmetry is the whole design and is easy to regress. +## E. Sidebar file operations (`feat/sidebar-file-ops`, 2026-08-17) + +The rules are gated hard (`cargo test -p fileops`, `tests/sidebar.test.ts`, +`tests/contextmenu.test.ts`), but three things sit outside every headless gate: the +*Windows* filesystem the name rules describe, the watcher's reaction to an operation +Toril itself performed, and whether the version history actually followed the file. + +- [ ] **E1 — Rename does not resurrect the old note.** The §3 case, and the reason + `doRenameEntry` orders its work the way it does. Open a note, rename it from the + sidebar, then wait for the watcher: the tab must follow to the new name, must + **not** show "removed on disk", and autosave must not recreate the old path. Then + repeat with the note *dirty*, and with a note open inside a folder you rename. +- [ ] **E2 — Version history followed the rename.** Save a note two or three times, + rename it, then open the history panel: the earlier versions must still be listed. + `snapshots::rekey` had **no caller before this branch**, so this is its first + real exercise. Repeat for a note inside a renamed folder (the subtree path). +- [ ] **E3 — Delete and Undo.** Delete a note with a tab open: the tab closes, the file + appears under `.trash/`, and the status bar offers Undo. Click it — the note comes + back at its original path. Then delete a note, create a *different* file at that + path, and Undo: it must refuse rather than clobber, and say the note is still in + `.trash`. Finally delete a **dirty** tab's file and confirm the native prompt + appears (the one native dialog this feature uses — see B7 for the Linux hazard). +- [ ] **E4 — Windows name rules against the real filesystem.** The crate's tests assert + what we *reject*; only Windows proves the rejections are the right ones. Try + `CON.md`, `note.` , `note ` (trailing space), `a:b.md` and a 300-character name — + each refused with a readable message beside the field. Then rename `notes.md` to + `Notes.md`: this **must succeed** on NTFS, where the destination "already exists" + as the source itself, and the sidebar must show the new casing. +- [ ] **E5 — Paths keep their spelling.** After creating a note from the sidebar, confirm + the tab, the sidebar highlight and the window title all agree, and that saving it + produces history under the same key. A `\\?\`-prefixed path would work for I/O and + silently split the note into two identities; the crate pins this, the app proves it. +- [ ] **E6 — The context menu in both engines.** It is `position: fixed` at `--z-dialog`. + Confirm in WebKitGTK as well as WebView2 that it is not clipped by a pane's + `overflow: hidden`, that right-clicking near the right edge or the bottom keeps it + on screen, and that Shift+F10 / the menu key opens it anchored to the row rather + than the window corner. +- [ ] **E7 — The refresh race.** Start typing a new note's name, then touch an unrelated + file in the vault from outside (or let a sync client do it). The field must keep + what you typed; the new tree must appear as soon as you confirm or cancel. + ## B. Standing items (pre-existing, not from this branch) - [ ] **B1 — HTML as a first-class format.** Open a real AI-authored `.html` artifact, diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 083b48c..1d99d84 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1216,6 +1216,10 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "fileops" +version = "0.1.0" + [[package]] name = "filetime" version = "0.2.29" @@ -4839,6 +4843,7 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" name = "toril-app" version = "1.0.0" dependencies = [ + "fileops", "fsatomic", "imgasset", "keystore", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 73e4263..62be048 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/snapshots", "crates/mergemd", "crates/keystore", + "crates/fileops", ] # Vendored, security-patched glib (see [patch.crates-io] below). Excluded so it # is never a workspace member — we don't fmt/clippy/test upstream third-party code. @@ -65,6 +66,7 @@ trashbin = { path = "crates/trashbin" } snapshots = { path = "crates/snapshots" } mergemd = { path = "crates/mergemd" } keystore = { path = "crates/keystore" } +fileops = { path = "crates/fileops" } # Self-update, desktop only (ROADMAP Movement I.5). `v1.0.0` shipped with no # update path, so every installed copy is stranded on the version it was diff --git a/src-tauri/crates/fileops/Cargo.toml b/src-tauri/crates/fileops/Cargo.toml new file mode 100644 index 0000000..3c8ff8d --- /dev/null +++ b/src-tauri/crates/fileops/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "fileops" +version = "0.1.0" +edition = "2024" +description = "Create / rename primitives for a Toril workspace (CLAUDE.md §3, ROADMAP II.12). Name validation, vault containment and clobber-refusing moves; no Tauri dep so the rules are unit-testable anywhere." + +[dependencies] diff --git a/src-tauri/crates/fileops/src/lib.rs b/src-tauri/crates/fileops/src/lib.rs new file mode 100644 index 0000000..5ac8091 --- /dev/null +++ b/src-tauri/crates/fileops/src/lib.rs @@ -0,0 +1,764 @@ +//! Create / rename primitives for a Toril workspace (CLAUDE.md §3, ROADMAP +//! Movement II.12 `feat/sidebar-file-ops`). +//! +//! Deleting is not here — that is `trashbin`, which soft-deletes into the +//! workspace `.trash/` and can restore. What is here is everything that brings a +//! path into existence or changes which path a note lives at: +//! +//! - [`validate_name`] — the rules a single path component must satisfy. +//! - [`create_note`] / [`create_folder`] — refuse to clobber, never overwrite. +//! - [`rename`] — same parent, new name; refuses to clobber. +//! - [`available_note_name`] — pick an unused default (`Untitled 2.md`). +//! - [`descendant_files`] — the subtree a folder rename moved, so version +//! history can follow it. +//! +//! **Two rules carry the safety, and both are enforced here rather than in the +//! caller.** Every operation requires its target to resolve *inside* the vault +//! root — the webview is untrusted (§3.3) and a crafted `invoke` must not be able +//! to write outside the folder the user opened. And every operation that creates +//! a path refuses an existing one instead of replacing it: there is no flag to +//! force it, because "rename over the note that was already there" is exactly the +//! silent loss §3 forbids. Mirrors `trashbin::move_to_trash`, which draws the +//! same containment boundary for the delete direction. +//! +//! No Tauri dependency: pure `std`, fully unit-tested. + +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Extensions Toril treats as a note, kept in step with `src/paths.ts`'s +/// `OPENABLE` and `tauri.conf.json`'s file associations. +const NOTE_EXTENSIONS: [&str; 4] = ["md", "markdown", "html", "htm"]; + +/// The extension a new note gets when the user types a bare name. +const DEFAULT_EXTENSION: &str = "md"; + +/// Longest single path component we will create. 255 is the limit on NTFS, ext4, +/// APFS and HFS+ alike; measured in bytes because that is what the strictest of +/// them counts. +const MAX_COMPONENT_BYTES: usize = 255; + +/// Characters no path component may contain. +/// +/// This is the **Windows** set, applied on every platform on purpose. Toril's +/// primary target is Windows (§1) but a vault is a plain folder that may be +/// synced from a Mac or a Linux box, so a name that is legal to create here and +/// impossible to open there is a portability bug we would be authoring into the +/// user's own files. `/` and `\` are in the set for a second reason as well: +/// they would turn one component into a path. +const FORBIDDEN_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +/// Device names Windows reserves, which cannot be used as a file name *even with +/// an extension* — `NUL.md` is still the null device. Creating one appears to +/// succeed and then behaves like a device, so it is rejected before we try. +const RESERVED_STEMS: [&str; 4] = ["con", "prn", "aux", "nul"]; + +/// Reserved device-name prefixes that take a single trailing digit (`COM1`, +/// `LPT9`). `0` is included: it is reserved on current Windows and costs nothing. +const RESERVED_NUMBERED: [&str; 2] = ["com", "lpt"]; + +fn invalid(msg: &str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, msg.to_string()) +} + +fn exists(path: &Path) -> io::Error { + io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "\"{}\" already exists", + path.file_name().unwrap_or_default().to_string_lossy() + ), + ) +} + +/// Whether `stem` is a Windows reserved device name (case-insensitive). +fn is_reserved_stem(stem: &str) -> bool { + let lower = stem.to_ascii_lowercase(); + if RESERVED_STEMS.contains(&lower.as_str()) { + return true; + } + RESERVED_NUMBERED.iter().any(|prefix| { + lower + .strip_prefix(prefix) + .is_some_and(|rest| rest.len() == 1 && rest.as_bytes()[0].is_ascii_digit()) + }) +} + +/// Check that `name` is a usable single path component. +/// +/// Rejects, with a message written for the user rather than the log: emptiness, +/// path separators and the other characters Windows forbids, control characters, +/// `.` / `..`, a trailing space or dot (Windows *silently strips* both, so the +/// file that appears is not the one that was asked for and the caller's returned +/// path would be a lie), Windows reserved device names, and anything over +/// [`MAX_COMPONENT_BYTES`]. +pub fn validate_name(name: &str) -> io::Result<()> { + if name.is_empty() { + return Err(invalid("Enter a name.")); + } + if name.trim().is_empty() { + return Err(invalid("A name cannot be only spaces.")); + } + if name.len() > MAX_COMPONENT_BYTES { + return Err(invalid("That name is too long.")); + } + if name == "." || name == ".." { + return Err(invalid("\".\" and \"..\" are not names.")); + } + if let Some(c) = name.chars().find(|c| FORBIDDEN_CHARS.contains(c)) { + return Err(invalid(&format!("A name cannot contain {c}"))); + } + if name.chars().any(|c| c.is_control()) { + return Err(invalid("A name cannot contain control characters.")); + } + if name.ends_with(' ') || name.ends_with('.') { + return Err(invalid("A name cannot end with a space or a dot.")); + } + // The stem before the *first* dot is what Windows matches a device against, + // so `nul.md` and `nul.tar.gz` are both the null device. + let stem = name.split('.').next().unwrap_or(name); + if is_reserved_stem(stem) { + return Err(invalid(&format!( + "\"{stem}\" is a reserved name on Windows." + ))); + } + Ok(()) +} + +/// Require `inside` to resolve to a location at or under `root`. Both must exist. +/// +/// `canonicalize` resolves `..` and symlinks, which is what makes this a +/// containment check rather than a string comparison, and on Windows it applies +/// the same verbatim prefix to both sides so they compare. +/// +/// **It returns nothing on purpose.** The obvious shape — hand back the resolved +/// path and build on that — would be wrong on Windows, where `canonicalize` +/// returns the extended-length `\\?\C:\…` form. Every path in this app is a +/// string that has to match: the sidebar tree's paths, the tab a path opens, +/// the key a note's version history is stored under. A `\\?\` path works for +/// I/O and matches none of them, so it would silently split one note into two +/// identities. Callers therefore validate here and keep building from the +/// caller's own path spelling. +fn require_inside(root: &Path, inside: &Path) -> io::Result<()> { + let canon_root = fs::canonicalize(root)?; + let canon = fs::canonicalize(inside)?; + if !canon.starts_with(&canon_root) { + return Err(invalid("That location is outside the open folder.")); + } + Ok(()) +} + +/// The name a new note gets on disk: `name` if it already carries a note +/// extension (case-insensitive), otherwise `name.md`. +/// +/// The condition is "*a note* extension", not "any extension", so a name that +/// merely contains a dot still becomes a note: `Meeting 2026.08.17` → +/// `Meeting 2026.08.17.md`, where an any-extension rule would read `.17` as +/// deliberate and leave a file the sidebar cannot show (`vaultscan` lists +/// markdown only). The cost is that `notes.txt` becomes `notes.txt.md` — the +/// right trade, because New Note means a note, and a wrong-looking name is +/// visible and one rename away, while an invisible file is neither. +pub fn with_note_extension(name: &str) -> String { + match Path::new(name).extension().and_then(OsStr::to_str) { + Some(ext) if NOTE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) => { + name.to_string() + } + _ => format!("{name}.{DEFAULT_EXTENSION}"), + } +} + +/// Create an empty note called `name` in `dir`, and return its path. +/// +/// `create_new` makes the existence check and the creation one atomic operation, +/// so two racing creates cannot both believe they won and one silently truncate +/// the other's note. The file is created **empty**: a new note's first bytes are +/// whatever the user types, and writing a template here would be content they did +/// not ask for (§3). +pub fn create_note(vault_root: &Path, dir: &Path, name: &str) -> io::Result { + let file_name = with_note_extension(name); + validate_name(&file_name)?; + require_inside(vault_root, dir)?; + let path = dir.join(&file_name); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(_) => Ok(path), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(exists(&path)), + Err(e) => Err(e), + } +} + +/// Create a folder called `name` inside `parent`, and return its path. +/// +/// `create_dir` (not `create_dir_all`) so an existing folder is an error rather +/// than a silent success — the caller asked to make something new, and reporting +/// success for a folder that was already there would let the UI claim it created +/// a note's home that in fact belongs to something else. +pub fn create_folder(vault_root: &Path, parent: &Path, name: &str) -> io::Result { + validate_name(name)?; + require_inside(vault_root, parent)?; + let path = parent.join(name); + match fs::create_dir(&path) { + Ok(()) => Ok(path), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(exists(&path)), + Err(e) => Err(e), + } +} + +/// Rename `from` to `new_name` within the same parent, and return the new path. +/// +/// A no-op rename (the same name, byte for byte) succeeds and returns the +/// existing path — otherwise confirming an unchanged inline edit would report a +/// clobber error for a file against itself. +/// +/// **`fs::rename` overwrites an existing destination on both Unix and Windows**, +/// so the existence check below is the only thing standing between a rename and +/// a destroyed note. It is a check-then-act, and therefore racy against another +/// process creating that exact path in the window between — the race is accepted +/// (`std` exposes no atomic rename-if-absent that is portable), and it is the +/// same shape `trashbin::restore` already carries. What makes it survivable is +/// that the loser was on disk and is therefore in version history (§3): a +/// clobber is recoverable, undetected-but-recoverable being the standing edge of +/// the guarantee (CLAUDE.md §5). +/// +/// A file's extension is **not** forced or preserved: renaming `note.md` to +/// `note.html` is allowed, because the bytes are untouched and it is the user's +/// file. The caller must not reinterpret an open document's format on the +/// strength of a rename — re-serializing content that did not change is the +/// unrequested rewrite this project exists to avoid. +pub fn rename(vault_root: &Path, from: &Path, new_name: &str) -> io::Result { + validate_name(new_name)?; + require_inside(vault_root, from)?; + let parent = from + .parent() + .ok_or_else(|| invalid("That item has no parent folder."))?; + let to = parent.join(new_name); + + if to == from { + return Ok(to); + } + // Case-only renames (`notes.md` → `Notes.md`) are a real thing users want, and + // on a case-insensitive filesystem they arrive here as "destination exists" — + // against the source file itself. Comparing *names* would not tell that apart + // (they differ, by exactly the case being changed), so resolve the + // destination and ask whether it is the same file: `canonicalize` returns the + // real on-disk name, which equals the already-canonicalized `from` when the + // two are one file. A destination that cannot be resolved is treated as a + // genuine occupant — failing closed on an ambiguous read is the safe + // direction when the alternative is a clobbering rename. + if to.exists() { + let same_file = match (fs::canonicalize(&to), fs::canonicalize(from)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + }; + if !same_file { + return Err(exists(&to)); + } + } + fs::rename(from, &to)?; + Ok(to) +} + +/// An unused `stem`-based note name in `dir` — `Untitled.md`, else `Untitled 2.md`, +/// `Untitled 3.md`, … +/// +/// Used for the default the inline rename field is pre-filled with, so pressing +/// New Note twice in a row proposes a name that will actually work rather than +/// one that fails on confirm. It is a *suggestion*: `create_note` still refuses a +/// collision, because the user can edit the field and something can appear on +/// disk between the suggestion and the create. +pub fn available_note_name(vault_root: &Path, dir: &Path, stem: &str) -> String { + let first = format!("{stem}.{DEFAULT_EXTENSION}"); + // Containment even though this only reads: it answers "does this file + // exist?" for any path handed to it, and the webview is untrusted (§3.3). + // On refusal it returns the plain first candidate — the caller is only + // pre-filling a field, and `create_note` enforces the same boundary for + // real. + if require_inside(vault_root, dir).is_err() { + return first; + } + if !dir.join(&first).exists() { + return first; + } + // Bounded so a pathological directory cannot spin forever; at that point the + // suggestion is wrong but harmless, and `create_note` reports the collision. + for n in 2..1000 { + let candidate = format!("{stem} {n}.{DEFAULT_EXTENSION}"); + if !dir.join(&candidate).exists() { + return candidate; + } + } + first +} + +/// Every regular file under `dir`, recursively, skipping hidden entries. +/// +/// Exists so a **folder** rename can carry version history: the snapshot store is +/// keyed by a note's absolute path, so moving a folder re-keys every note beneath +/// it, and the caller needs the before-list to pair with the after-list. Hidden +/// entries are skipped for the same reason `vaultscan` skips them — `.trash/` and +/// `.obsidian/` are not the user's notes. +/// +/// Unreadable subdirectories are skipped rather than failing the walk: this feeds +/// a best-effort, additive history migration (§3), and one permission-denied +/// folder must not turn a successful rename into a reported failure. +pub fn descendant_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + collect_files(dir, &mut out); + out.sort(); + out +} + +fn collect_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + if name.to_string_lossy().starts_with('.') { + continue; + } + let path = entry.path(); + match entry.file_type() { + Ok(t) if t.is_dir() => collect_files(&path, out), + Ok(t) if t.is_file() => out.push(path), + _ => {} + } + } +} + +/// Re-root `path` from under `old_root` to under `new_root`. +/// +/// The other half of the folder-rename history migration: given the file list +/// taken before the move, this says where each one landed. Returns `None` if +/// `path` was not under `old_root`, so a caller cannot accidentally map an +/// unrelated path into the new tree. +pub fn reroot(path: &Path, old_root: &Path, new_root: &Path) -> Option { + let rest = path.strip_prefix(old_root).ok()?; + Some(new_root.join(rest)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempDir(PathBuf); + impl TempDir { + fn new(tag: &str) -> TempDir { + static N: AtomicU64 = AtomicU64::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("fileops-{tag}-{nanos}-{n}")); + fs::create_dir_all(&dir).unwrap(); + TempDir(dir) + } + fn path(&self) -> &Path { + &self.0 + } + } + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn write_file(path: &Path, contents: &str) { + if let Some(p) = path.parent() { + fs::create_dir_all(p).unwrap(); + } + fs::write(path, contents).unwrap(); + } + + // ---- validate_name ---------------------------------------------------- + + #[test] + fn accepts_ordinary_names() { + for name in [ + "note.md", + "Meeting Notes.md", + "2026-08-17.md", + "café ☕.md", + "a.very.dotted.name.md", + "-leading-dash.md", + ".hidden.md", // legal; vaultscan just won't list it + ] { + assert!(validate_name(name).is_ok(), "should accept {name}"); + } + } + + #[test] + fn rejects_empty_and_whitespace() { + assert!(validate_name("").is_err()); + assert!(validate_name(" ").is_err()); + } + + #[test] + fn rejects_path_separators_so_a_name_cannot_become_a_path() { + assert!(validate_name("sub/note.md").is_err()); + assert!(validate_name("sub\\note.md").is_err()); + assert!(validate_name("../escape.md").is_err()); + } + + #[test] + fn rejects_the_windows_forbidden_set_on_every_platform() { + for name in [ + "ab.md", "a:b.md", "a\"b.md", "a|b.md", "a?b.md", "a*b.md", + ] { + assert!(validate_name(name).is_err(), "should reject {name}"); + } + } + + #[test] + fn rejects_control_characters() { + assert!(validate_name("note\u{0}.md").is_err()); + assert!(validate_name("note\n.md").is_err()); + assert!(validate_name("note\t.md").is_err()); + } + + #[test] + fn rejects_dot_names_and_trailing_dot_or_space() { + assert!(validate_name(".").is_err()); + assert!(validate_name("..").is_err()); + // Windows strips these silently, so the created file would not be the + // one we returned a path for. + assert!(validate_name("note.").is_err()); + assert!(validate_name("note ").is_err()); + assert!(validate_name("...").is_err()); + } + + #[test] + fn rejects_windows_reserved_device_names_with_or_without_extension() { + for name in [ + "CON", + "con", + "NUL.md", + "nul.tar.gz", + "AUX.md", + "prn.markdown", + "COM1.md", + "lpt9.md", + "COM0.md", + ] { + assert!(validate_name(name).is_err(), "should reject {name}"); + } + // Not reserved: a longer word that merely starts with one, or a + // multi-digit port that Windows does not reserve. + for name in ["console.md", "connection.md", "com10.md", "auxiliary.md"] { + assert!(validate_name(name).is_ok(), "should accept {name}"); + } + } + + #[test] + fn rejects_an_overlong_component() { + let long = format!("{}.md", "a".repeat(300)); + assert!(validate_name(&long).is_err()); + let ok = format!("{}.md", "a".repeat(200)); + assert!(validate_name(&ok).is_ok()); + } + + // ---- extension handling ------------------------------------------------ + + #[test] + fn appends_md_only_when_the_name_lacks_a_note_extension() { + assert_eq!(with_note_extension("note"), "note.md"); + assert_eq!(with_note_extension("note.md"), "note.md"); + assert_eq!(with_note_extension("note.MD"), "note.MD"); + assert_eq!(with_note_extension("note.markdown"), "note.markdown"); + assert_eq!(with_note_extension("page.html"), "page.html"); + // Not a *note* extension, so it still gets one — New Note makes a note. + assert_eq!(with_note_extension("notes.txt"), "notes.txt.md"); + // The case that rule exists for: a dotted name is not an extension. + assert_eq!( + with_note_extension("Meeting 2026.08.17"), + "Meeting 2026.08.17.md" + ); + assert_eq!(with_note_extension("v1.2.3"), "v1.2.3.md"); + } + + // ---- create_note ------------------------------------------------------- + + #[test] + fn creates_an_empty_note_and_returns_its_path() { + let t = TempDir::new("create"); + let path = create_note(t.path(), t.path(), "First note").unwrap(); + + assert_eq!(path.file_name().unwrap(), "First note.md"); + assert_eq!(fs::read_to_string(&path).unwrap(), ""); + } + + /// Every returned path must be spelled the way the caller spelled it. + /// + /// The regression this pins: containment is checked with `canonicalize`, + /// which on Windows returns the extended-length `\\?\C:\…` form. Building + /// the result from that would hand back a path that does real I/O fine and + /// matches nothing else in the app — not the sidebar tree, not an open + /// tab's path, not the note's version-history key — silently splitting one + /// note into two identities. + #[test] + fn returned_paths_keep_the_callers_spelling() { + let t = TempDir::new("spelling"); + let dir = t.path(); + + let created = create_note(dir, dir, "note").unwrap(); + assert_eq!(created, dir.join("note.md")); + + let folder = create_folder(dir, dir, "sub").unwrap(); + assert_eq!(folder, dir.join("sub")); + + let renamed = rename(dir, &created, "other.md").unwrap(); + assert_eq!(renamed, dir.join("other.md")); + + for path in [&created, &folder, &renamed] { + assert!( + !path.to_string_lossy().contains(r"\\?\"), + "{path:?} leaked a canonicalized prefix" + ); + } + } + + #[test] + fn create_note_refuses_to_clobber_an_existing_note() { + let t = TempDir::new("clobber"); + write_file(&t.path().join("note.md"), "precious"); + + let err = create_note(t.path(), t.path(), "note.md").unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + assert_eq!( + fs::read_to_string(t.path().join("note.md")).unwrap(), + "precious", + "the existing note must be untouched" + ); + } + + #[test] + fn create_note_refuses_a_directory_outside_the_vault() { + let t = TempDir::new("outside"); + let vault = t.path().join("vault"); + let elsewhere = t.path().join("elsewhere"); + fs::create_dir_all(&vault).unwrap(); + fs::create_dir_all(&elsewhere).unwrap(); + + let err = create_note(&vault, &elsewhere, "escaped.md").unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!( + !elsewhere.join("escaped.md").exists(), + "nothing may be created outside the vault" + ); + } + + #[test] + fn create_note_refuses_a_traversal_dressed_up_as_a_subdirectory() { + let t = TempDir::new("traverse"); + let vault = t.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + fs::create_dir_all(t.path().join("elsewhere")).unwrap(); + + let sneaky = vault.join("..").join("elsewhere"); + let err = create_note(&vault, &sneaky, "escaped.md").unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + // ---- create_folder ----------------------------------------------------- + + #[test] + fn creates_a_folder_and_refuses_an_existing_one() { + let t = TempDir::new("folder"); + let path = create_folder(t.path(), t.path(), "Projects").unwrap(); + assert!(path.is_dir()); + + let err = create_folder(t.path(), t.path(), "Projects").unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + } + + #[test] + fn create_folder_does_not_append_an_extension() { + let t = TempDir::new("folder-ext"); + let path = create_folder(t.path(), t.path(), "notes").unwrap(); + assert_eq!(path.file_name().unwrap(), "notes"); + } + + // ---- rename ------------------------------------------------------------ + + #[test] + fn renames_within_the_same_parent() { + let t = TempDir::new("rename"); + let sub = t.path().join("sub"); + fs::create_dir_all(&sub).unwrap(); + let from = sub.join("old.md"); + write_file(&from, "body"); + + let to = rename(t.path(), &from, "new.md").unwrap(); + + assert_eq!(to.parent().unwrap().file_name().unwrap(), "sub"); + assert_eq!(to.file_name().unwrap(), "new.md"); + assert_eq!(fs::read_to_string(&to).unwrap(), "body"); + assert!(!from.exists()); + } + + #[test] + fn rename_refuses_to_clobber_a_different_file() { + let t = TempDir::new("rename-clobber"); + let from = t.path().join("a.md"); + let occupied = t.path().join("b.md"); + write_file(&from, "mine"); + write_file(&occupied, "theirs"); + + let err = rename(t.path(), &from, "b.md").unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read_to_string(&occupied).unwrap(), "theirs"); + assert_eq!( + fs::read_to_string(&from).unwrap(), + "mine", + "a refused rename must leave the source in place" + ); + } + + #[test] + fn renaming_to_the_same_name_is_a_no_op_not_a_collision() { + let t = TempDir::new("rename-same"); + let from = t.path().join("note.md"); + write_file(&from, "body"); + + let to = rename(t.path(), &from, "note.md").unwrap(); + + assert_eq!(fs::read_to_string(&to).unwrap(), "body"); + } + + #[test] + fn case_only_rename_is_allowed() { + // On a case-insensitive filesystem the destination "exists" — it is the + // same file. Must not be reported as a collision. + let t = TempDir::new("rename-case"); + let from = t.path().join("notes.md"); + write_file(&from, "body"); + + let to = rename(t.path(), &from, "Notes.md").unwrap(); + + assert_eq!(to.file_name().unwrap(), "Notes.md"); + assert_eq!(fs::read_to_string(&to).unwrap(), "body"); + } + + #[test] + fn rename_refuses_a_name_that_is_a_path() { + let t = TempDir::new("rename-path"); + let from = t.path().join("note.md"); + write_file(&from, "body"); + + assert!(rename(t.path(), &from, "../escaped.md").is_err()); + assert!(rename(t.path(), &from, "sub/escaped.md").is_err()); + assert!(from.exists()); + } + + #[test] + fn rename_refuses_a_source_outside_the_vault() { + let t = TempDir::new("rename-outside"); + let vault = t.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + let outside = t.path().join("outside.md"); + write_file(&outside, "not yours"); + + let err = rename(&vault, &outside, "taken.md").unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!(outside.exists()); + } + + #[test] + fn renames_a_folder_with_its_contents() { + let t = TempDir::new("rename-dir"); + let dir = t.path().join("old"); + write_file(&dir.join("a.md"), "a"); + write_file(&dir.join("deep").join("b.md"), "b"); + + let to = rename(t.path(), &dir, "new").unwrap(); + + assert_eq!(fs::read_to_string(to.join("a.md")).unwrap(), "a"); + assert_eq!(fs::read_to_string(to.join("deep/b.md")).unwrap(), "b"); + assert!(!dir.exists()); + } + + // ---- available_note_name ----------------------------------------------- + + #[test] + fn suggests_the_first_unused_name() { + let t = TempDir::new("suggest"); + let vault = t.path(); + assert_eq!(available_note_name(vault, vault, "Untitled"), "Untitled.md"); + + write_file(&vault.join("Untitled.md"), ""); + assert_eq!( + available_note_name(vault, vault, "Untitled"), + "Untitled 2.md" + ); + + write_file(&vault.join("Untitled 2.md"), ""); + assert_eq!( + available_note_name(vault, vault, "Untitled"), + "Untitled 3.md" + ); + } + + #[test] + fn suggestion_does_not_probe_outside_the_vault() { + let t = TempDir::new("suggest-outside"); + let vault = t.path().join("vault"); + let elsewhere = t.path().join("elsewhere"); + fs::create_dir_all(&vault).unwrap(); + fs::create_dir_all(&elsewhere).unwrap(); + write_file(&elsewhere.join("Untitled.md"), ""); + + // Would be `Untitled 2.md` if the existence check had run out there. + assert_eq!( + available_note_name(&vault, &elsewhere, "Untitled"), + "Untitled.md" + ); + } + + // ---- subtree walk / reroot --------------------------------------------- + + #[test] + fn lists_descendant_files_and_skips_hidden() { + let t = TempDir::new("walk"); + write_file(&t.path().join("a.md"), "a"); + write_file(&t.path().join("deep/b.md"), "b"); + write_file(&t.path().join(".trash/gone/c.md"), "c"); + write_file(&t.path().join(".hidden.md"), "h"); + + let files = descendant_files(t.path()); + + assert_eq!(files.len(), 2, "got {files:?}"); + assert!(files.iter().any(|p| p.ends_with("a.md"))); + assert!(files.iter().any(|p| p.ends_with("b.md"))); + } + + #[test] + fn descendant_files_of_a_missing_directory_is_empty_not_an_error() { + let t = TempDir::new("walk-missing"); + assert!(descendant_files(&t.path().join("nope")).is_empty()); + } + + #[test] + fn reroot_maps_a_subtree_path_and_refuses_an_unrelated_one() { + let old = Path::new("/vault/old"); + let new = Path::new("/vault/new"); + + assert_eq!( + reroot(Path::new("/vault/old/deep/b.md"), old, new), + Some(PathBuf::from("/vault/new/deep/b.md")) + ); + assert_eq!(reroot(Path::new("/vault/other/b.md"), old, new), None); + } +} diff --git a/src-tauri/crates/vaultscan/src/lib.rs b/src-tauri/crates/vaultscan/src/lib.rs index c6230e6..e5e386e 100644 --- a/src-tauri/crates/vaultscan/src/lib.rs +++ b/src-tauri/crates/vaultscan/src/lib.rs @@ -6,8 +6,11 @@ //! - Hidden entries (name starting with `.`) are skipped — this drops `.git`, //! `.obsidian`, etc. //! - Only `.md` / `.markdown` files are listed. -//! - A directory is included only if its subtree contains at least one markdown -//! file, so empty / asset-only folders don't clutter the tree. +//! - A directory is included if its subtree contains at least one markdown file +//! **or** it is empty, so asset-only folders don't clutter the tree but a +//! folder the user just made is still there. That second half is not a +//! refinement — without it, New Folder (ROADMAP II.12) creates a directory +//! that immediately disappears and cannot be put a note into. //! - Entries are sorted directories-first, then case-insensitive by name. //! //! No Tauri dependency: the walk is pure `std` + `serde` and fully unit-tested. @@ -46,7 +49,7 @@ fn scan_dir(dir: &Path) -> io::Result> { if file_type.is_dir() { let children = scan_dir(&path)?; - if !children.is_empty() { + if !children.is_empty() || is_visibly_empty(&path) { nodes.push(FileNode { name, path: path.to_string_lossy().into_owned(), @@ -72,6 +75,23 @@ fn scan_dir(dir: &Path) -> io::Result> { Ok(nodes) } +/// Whether `dir` holds nothing the user would see — no entries at all, or only +/// hidden ones. +/// +/// Distinguishes "you just made this" from "this is an assets folder". A folder +/// with a PNG in it stays pruned; a folder with nothing in it is shown, because +/// the alternative is New Folder appearing to do nothing. An unreadable +/// directory answers `false`: unknown is not empty, and guessing the other way +/// would put a folder in the tree that nothing can be done with. +fn is_visibly_empty(dir: &Path) -> bool { + match fs::read_dir(dir) { + Ok(entries) => !entries + .flatten() + .any(|e| !e.file_name().to_string_lossy().starts_with('.')), + Err(_) => false, + } +} + fn is_markdown(name: &str) -> bool { let lower = name.to_lowercase(); lower.ends_with(".md") || lower.ends_with(".markdown") @@ -139,6 +159,24 @@ mod tests { assert!(file.path.ends_with("b-note.md")); } + /// A folder the user just made has nothing in it yet. Pruning it would make + /// New Folder (ROADMAP II.12) look like it had failed, and leave nowhere to + /// put the first note. + #[test] + fn keeps_an_empty_directory_but_still_prunes_an_asset_only_one() { + let t = TempDir::new(); + let root = &t.0; + touch(&root.join("note.md")); + fs::create_dir_all(root.join("Brand new")).unwrap(); + touch(&root.join("assets/pic.png")); + // Only hidden entries: nothing the user can see, so it counts as empty. + touch(&root.join("quiet/.keep")); + + let names: Vec = scan(root).unwrap().into_iter().map(|n| n.name).collect(); + + assert_eq!(names, ["Brand new", "quiet", "note.md"]); + } + #[test] fn empty_folder_yields_empty_tree() { let t = TempDir::new(); diff --git a/src-tauri/src/commands/entries.rs b/src-tauri/src/commands/entries.rs new file mode 100644 index 0000000..1664e55 --- /dev/null +++ b/src-tauri/src/commands/entries.rs @@ -0,0 +1,114 @@ +//! Workspace entry commands (CLAUDE.md §5, ROADMAP Movement II.12): create a +//! note, create a folder, rename either. +//! +//! Thin wrappers over the `fileops` crate (aliased `ops`, the `commands/snapshots.rs` +//! precedent) — the rules that make these safe live there and are unit-tested +//! without Tauri: name validation, vault containment, and a refusal to clobber. +//! Deleting is not here; it is `commands/trash.rs`, which soft-deletes and can +//! restore. +//! +//! The one piece of real logic in this file is **carrying version history across +//! a rename**. The snapshot store is keyed by a note's absolute path, so a rename +//! would otherwise orphan every version of that note behind a path nothing points +//! at any more. `snapshots::rekey` exists for exactly this and has had no caller +//! until now. It is applied **best-effort and additively** (§3), matching how +//! snapshots are taken on save: the rename already succeeded on disk, and history +//! failing to follow must not turn it into a reported failure — the user would be +//! left with a completed rename and an error message, unable to tell what state +//! anything is in. + +use std::path::{Path, PathBuf}; + +use fileops as ops; +use tauri::AppHandle; + +fn as_string(path: PathBuf) -> String { + path.to_string_lossy().into_owned() +} + +/// Create an empty note called `name` inside `dir`, and return its path. +/// +/// `name` may omit the extension (`.md` is added). Refuses to overwrite an +/// existing file and refuses a `dir` outside `vault_root`. +#[tauri::command] +pub fn create_note(vault_root: String, dir: String, name: String) -> Result { + ops::create_note(Path::new(&vault_root), Path::new(&dir), &name) + .map(as_string) + .map_err(|e| e.to_string()) +} + +/// Create a folder called `name` inside `parent`, and return its path. +#[tauri::command] +pub fn create_folder(vault_root: String, parent: String, name: String) -> Result { + ops::create_folder(Path::new(&vault_root), Path::new(&parent), &name) + .map(as_string) + .map_err(|e| e.to_string()) +} + +/// A name the caller can safely propose for a new note in `dir` (`Untitled.md`, +/// `Untitled 2.md`, …). +/// +/// A suggestion only — `create_note` still refuses a collision, because the user +/// edits the field before confirming and a sync daemon can land a file in +/// between. Its job is to stop New Note twice in a row from proposing a name +/// that is already taken. +#[tauri::command] +pub fn suggest_note_name(vault_root: String, dir: String, stem: String) -> Result { + Ok(ops::available_note_name( + Path::new(&vault_root), + Path::new(&dir), + &stem, + )) +} + +/// Rename the file or folder at `path` to `new_name`, within the same parent. +/// +/// Returns the new path. Version history follows the note (or, for a folder, +/// every note beneath it) — see the module docs for why that is best-effort. +#[tauri::command] +pub fn rename_entry( + app: AppHandle, + vault_root: String, + path: String, + new_name: String, +) -> Result { + let from = Path::new(&path); + let was_dir = from.is_dir(); + // Taken *before* the move: after it, the old tree no longer exists to walk. + let old_files = if was_dir { + ops::descendant_files(from) + } else { + Vec::new() + }; + + let to = ops::rename(Path::new(&vault_root), from, &new_name).map_err(|e| e.to_string())?; + + // The rename is done. Everything below is additive (§3) and cannot fail it. + // + // `fileops` returns paths in the caller's own spelling, so the pre-move file + // list (built by walking `from`) and the new root (`to`) share a prefix and + // `reroot` pairs them directly — no canonicalization to reconcile. + if was_dir { + for old_file in old_files { + let Some(new_file) = ops::reroot(&old_file, from, &to) else { + continue; + }; + carry_history(&app, &as_string(old_file), &as_string(new_file)); + } + } else { + carry_history(&app, &path, &as_string(to.clone())); + } + + Ok(as_string(to)) +} + +/// Move one note's version history from `old_path` to `new_path`. Logged and +/// swallowed on failure — see the module docs. +fn carry_history(app: &AppHandle, old_path: &str, new_path: &str) { + let Ok(root) = super::snapshots::history_root(app) else { + return; + }; + if let Err(e) = snapshots::rekey(&root, old_path, new_path) { + eprintln!("version-history: rekey failed for {old_path} → {new_path}: {e}"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2d31541..a636061 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod entries; pub mod export; pub mod files; pub mod images; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12851c1..d3addb9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -84,6 +84,10 @@ pub fn run() { commands::recovery::save_recovery, commands::recovery::load_recovery, commands::recovery::clear_recovery, + commands::entries::create_note, + commands::entries::create_folder, + commands::entries::suggest_note_name, + commands::entries::rename_entry, commands::trash::move_to_trash, commands::trash::list_trash, commands::trash::restore_from_trash, diff --git a/src/ipc.ts b/src/ipc.ts index 76d5175..dcb0fc3 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -68,6 +68,82 @@ export function openFolder(path: string): Promise { return invoke("open_folder", { path }); } +/** + * Create an empty note called `name` inside `dir` (§5, ROADMAP II.12). + * + * `name` may omit the extension — Rust adds `.md`. Rejects, rather than + * overwrites, an existing file, and rejects a `dir` outside `vaultRoot`; both + * rules live in `crates/fileops` so there is one place they are enforced and + * one place they are tested. Resolves to the new note's path. + */ +export function createNote(vaultRoot: string, dir: string, name: string): Promise { + return invoke("create_note", { vaultRoot, dir, name }); +} + +/** Create a folder called `name` inside `parent`. Resolves to its path. */ +export function createFolder(vaultRoot: string, parent: string, name: string): Promise { + return invoke("create_folder", { vaultRoot, parent, name }); +} + +/** + * A note name not currently taken in `dir` — `Untitled.md`, `Untitled 2.md`, … + * + * Only a suggestion for the inline field's initial value; {@link createNote} + * still refuses a collision. + */ +export function suggestNoteName( + vaultRoot: string, + dir: string, + stem: string, +): Promise { + return invoke("suggest_note_name", { vaultRoot, dir, stem }); +} + +/** + * Rename the file or folder at `path` to `newName`, in the same parent. + * + * Resolves to the new path. Version history follows the note (or every note + * under a renamed folder) via `snapshots::rekey`, best-effort — see + * `commands/entries.rs`. Refuses to overwrite a different existing entry. + */ +export function renameEntry( + vaultRoot: string, + path: string, + newName: string, +): Promise { + return invoke("rename_entry", { vaultRoot, path, newName }); +} + +/** A soft-deleted item in the workspace trash (mirrors Rust `trashbin::TrashEntry`). */ +export interface TrashEntry { + id: string; + original_path: string; + name: string; + deleted_at: number; +} + +/** + * Soft-delete `path` into `/.trash/` (§3). Resolves to the entry, + * whose `id` is what {@link restoreFromTrash} takes — which is what makes the + * delete undoable rather than merely recoverable-if-you-know-where-to-look. + */ +export function moveToTrash(vaultRoot: string, path: string): Promise { + return invoke("move_to_trash", { vaultRoot, path }); +} + +/** The workspace trash, newest first. */ +export function listTrash(vaultRoot: string): Promise { + return invoke("list_trash", { vaultRoot }); +} + +/** + * Restore trash entry `id` to its original path. Resolves to that path; rejects + * (without clobbering) if a file has since reappeared there. + */ +export function restoreFromTrash(vaultRoot: string, id: string): Promise { + return invoke("restore_from_trash", { vaultRoot, id }); +} + /** Start watching `path` for external changes; events arrive via {@link onWorkspaceChange}. */ export function watchFolder(path: string): Promise { return invoke("watch_folder", { path }); @@ -138,6 +214,19 @@ export async function installCloseGuard( }); } +/** + * Ask the user to confirm an action that would discard unsaved work. + * + * Native, like the close guard's prompt and for the same reason: this is the + * question that must not be missable, and an in-page banner can be scrolled + * past or ignored. It is deliberately the *only* native dialog the file + * operations use — every other decision they need is answered inline in the + * sidebar, where it can be driven by the headless gates. + */ +export function confirmDiscard(question: string): Promise { + return ask(question, { title: "Toril", kind: "warning" }); +} + /** Show the native "About Toril" dialog (Help menu). */ export async function showAbout(): Promise { let version = ""; diff --git a/src/main.ts b/src/main.ts index 1bff961..d36a363 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,10 +19,14 @@ import { type WorkspaceChange, checkForUpdate, clearRecovery, + confirmDiscard, + createFolder, + createNote, exportHtml, exportRtf, installCloseGuard, listHistory, + moveToTrash, loadRecovery, loadSettings, markdownToHtml, @@ -39,6 +43,8 @@ import { pickFolder, readSnapshot, relaunchApp, + renameEntry, + restoreFromTrash, restoreSnapshot, saveClipboardImage, saveFile, @@ -47,7 +53,9 @@ import { saveSettings, setRecentFiles, showAbout, + suggestNoteName, takeLaunchPath, + type TrashEntry, watchFolder, writeConflictCopy, } from "./ipc"; @@ -178,6 +186,31 @@ function setStatus(msg: string): void { if (el) el.textContent = msg; } +/** + * A status message with one inline action — currently Undo, after a delete. + * + * It lives in the status bar rather than a toast because it must not steal + * focus or cover anything: the user just deleted a file and may be about to + * click another. The next `setStatus` clears it, which is the dismissal — and + * missing the button costs nothing, since the file is sitting in `.trash/` + * either way. This is an affordance for the common case, not the safety net; + * the safety net is that the delete was never destructive. + */ +function setStatusWithAction(msg: string, label: string, onAction: () => void): void { + const el = document.querySelector("#status"); + if (!el) return; + el.textContent = `${msg} `; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "status-action"; + btn.textContent = label; + btn.addEventListener("click", () => { + setStatus(""); // one action, one use — a second click would restore twice + onAction(); + }); + el.append(btn); +} + function updateTitle(): void { const tab = tabs.active(); const shown = tab ? `${tab.name}${tab.dirty ? " *" : ""}` : "Toril"; @@ -357,7 +390,7 @@ async function doOpenFolder(): Promise { async function loadWorkspace(path: string): Promise { const tree = await openFolder(path); workspaceRoot = path; - sidebar.setRoot(basename(path), tree); + sidebar.setRoot(basename(path), tree, path); sidebar.setActivePath(tabs.active()?.path ?? null); setStatus(`Opened folder ${basename(path)}`); @@ -571,6 +604,167 @@ function doNew(): void { setStatus("New document"); } +// ---- Workspace file operations (ROADMAP Movement II.12) --------------------- +// +// The rules live in Rust (`crates/fileops` validates and refuses to clobber; +// `trashbin` soft-deletes) and the UX lives in `ui/sidebar.ts`. What is here is +// the part neither of them can own: keeping the *open tabs* consistent with +// what just happened on disk. +// +// That is the §3 surface of this feature. A rename moves the file out from under +// every tab pointing into it, and the watcher reports it as a delete followed by +// a create — the exact shape `removedOnDisk` exists to catch. Left alone, the +// tab would decide its file had vanished and offer to recreate it, resurrecting +// the old note beside its new name. + +/** + * Create a note and open it. + * + * Opening is the point: creating a file the user then has to find in the tree + * is a worse New Note than the Untitled buffer they already have. Rejections + * propagate — the sidebar keeps the name field open and shows the message. + */ +async function doCreateNote(dir: string, name: string): Promise { + if (!workspaceRoot) throw new Error("No folder is open."); + const path = await createNote(workspaceRoot, dir, name); + scheduleSidebarRefresh(); + // The file is empty and was just written by us, so there is nothing to read + // back: open the buffer directly rather than paying a round trip to learn it + // is empty. `base` is "" for the same reason, which is exactly right — that + // is what is on disk. + openDocument(path, basename(path), "", formatForPath(path)); + rememberRecent(path); + scheduleSessionSave(); + setStatus(`Created ${basename(path)}`); +} + +async function doCreateFolder(parent: string, name: string): Promise { + if (!workspaceRoot) throw new Error("No folder is open."); + await createFolder(workspaceRoot, parent, name); + scheduleSidebarRefresh(); + setStatus(`Created ${name}`); +} + +/** + * Rename a note or folder, and move every affected tab with it. + * + * Three things have to happen together, and the order matters: + * + * 1. **Re-point the tabs**, including every tab *underneath* a renamed folder. + * 2. **Bump the removal epoch** for each old path. A `reconcile` may already be + * in flight against the old path; it captured that path before its await and + * would apply its verdict — "missing", most likely — to a tab that has since + * moved. This is the same guard the watcher's remove branch uses. + * 3. **Clear `removedOnDisk`**, because the watcher's delete event for the old + * path may have landed while the rename was in flight. Steps 1–3 run in one + * synchronous block after the await, so no event can interleave between them. + * + * `base` is deliberately *not* reset: a rename does not change a single byte, so + * the merge base is still exactly right and re-reading would only invite a race. + */ +async function doRenameEntry(path: string, newName: string): Promise { + if (!workspaceRoot) throw new Error("No folder is open."); + const newPath = await renameEntry(workspaceRoot, path, newName); + + for (const tab of tabs.list()) { + const old = tab.path; + if (!old) continue; + // Exact match is the file case; the prefix branch is the folder case. The + // prefix is a raw slice because both strings come from the same scan of the + // same tree, so they share a spelling — `isAtOrUnder` is the predicate, + // `startsWith` is what makes the arithmetic safe. + let moved: string | null = null; + if (old === path) moved = newPath; + else if (isAtOrUnder(old, path) && old.startsWith(path)) moved = newPath + old.slice(path.length); + if (moved === null) continue; + + // A queued reconcile for the old path would read a file that no longer + // exists; cancel it as well as invalidating one already running. + const queued = reconcileTimers.get(old); + if (queued) { + clearTimeout(queued); + reconcileTimers.delete(old); + } + removalEpoch.set(old, (removalEpoch.get(old) ?? 0) + 1); + + tabs.setPath(tab.id, moved, basename(moved)); + tabs.setRemovedOnDisk(tab.id, false); + dropRecent(old); + rememberRecent(moved); + } + + scheduleSidebarRefresh(); + updateTitle(); + renderConflictBar(); + scheduleSessionSave(); // the session's open-file paths just changed + setStatus(`Renamed to ${basename(newPath)}`); +} + +/** + * Soft-delete a note or folder, and offer to undo it. + * + * The delete is reversible by construction — `trashbin` moves the file into the + * workspace `.trash/` rather than unlinking it — so this asks nothing in the + * ordinary case, and offers Undo instead. The one thing trash cannot bring back + * is an *unsaved buffer*, so that is the one case that stops and asks. + */ +async function doDeleteEntry(path: string, isDir: boolean): Promise { + if (!workspaceRoot) return; + const affected = tabs + .list() + .filter((t) => t.path !== null && (isDir ? isAtOrUnder(t.path, path) : t.path === path)); + + const unsaved = affected.filter((t) => t.dirty); + if (unsaved.length > 0) { + const names = unsaved.map((t) => t.name).join(", "); + const verb = unsaved.length === 1 ? "has" : "have"; + const ok = await confirmDiscard( + `${names} ${verb} unsaved changes that trash cannot bring back. Delete anyway?`, + ); + if (!ok) return; + } + + let entry: TrashEntry; + try { + entry = await moveToTrash(workspaceRoot, path); + } catch (e) { + setStatus(`Delete failed: ${String(e)}`); + return; + } + + // Closed only after the move succeeded — a failed delete must not also cost + // the user their open tab. + for (const tab of affected) { + if (tab.path) { + removalEpoch.set(tab.path, (removalEpoch.get(tab.path) ?? 0) + 1); + // Offering a deleted note in Open Recent is offering a failure; restoring + // it from trash re-opens it and puts it back at the front. + dropRecent(tab.path); + } + tabs.close(tab.id); + } + if (!isDir) dropRecent(path); // deleted without ever being opened + scheduleSidebarRefresh(); + updateTitle(); + renderConflictBar(); + scheduleSessionSave(); + setStatusWithAction(`Moved ${entry.name} to trash`, "Undo", () => void undoDelete(entry)); +} + +async function undoDelete(entry: TrashEntry): Promise { + if (!workspaceRoot) return; + try { + const path = await restoreFromTrash(workspaceRoot, entry.id); + scheduleSidebarRefresh(); + setStatus(`Restored ${basename(path)}`); + } catch (e) { + // `restore` refuses rather than clobbers when something has reappeared at + // the path, so the file is still safe in `.trash/` — say so, because the + // difference between "not restored" and "lost" is the whole point. + setStatus(`Could not restore ${entry.name} — it is still in .trash (${String(e)})`); + } +} + // ---- Pane layout (sidebar + tabbed rail) ----------------------------------- // // All pane decisions live in `panes.ts` as value transforms; this half only @@ -1051,7 +1245,7 @@ function scheduleSidebarRefresh(): void { if (!workspaceRoot) return; openFolder(workspaceRoot) .then((tree) => { - sidebar.setRoot(basename(workspaceRoot!), tree); + sidebar.setRoot(basename(workspaceRoot!), tree, workspaceRoot); sidebar.setActivePath(tabs.active()?.path ?? null); }) .catch(() => {}); @@ -1663,6 +1857,15 @@ window.addEventListener("DOMContentLoaded", async () => { // Routed through the same action as the menu item, so the empty state's // button cannot drift from File → Open Folder. onOpenFolder: () => runAction("menu_open_folder"), + // These three deliberately do **not** catch: the sidebar keeps its name + // field open on a rejection and shows the message beside it, which is where + // a bad name belongs. Swallowing the error here would leave the field + // looking like it had worked. + onCreateNote: (dir, name) => doCreateNote(dir, name), + onCreateFolder: (parent, name) => doCreateFolder(parent, name), + onRename: (path, newName) => doRenameEntry(path, newName), + onDelete: (path, isDir) => void doDeleteEntry(path, isDir), + suggestName: (dir) => suggestNoteName(workspaceRoot ?? dir, dir, "Untitled"), }); sidebar.setRoot(null, []); tabs = new TabManager(tabbar, { onDeactivate, onActivate, onCloseRequest }); diff --git a/src/styles/chrome.css b/src/styles/chrome.css index 4afe0b1..422b577 100644 --- a/src/styles/chrome.css +++ b/src/styles/chrome.css @@ -206,6 +206,9 @@ body { } .sidebar-root { + display: flex; + align-items: center; + gap: var(--sp-1); font-weight: 600; padding: var(--sp-1) var(--sp-1) var(--sp-2); text-transform: uppercase; @@ -214,6 +217,118 @@ body { color: var(--dim); } +/* The folder name yields to the buttons rather than pushing them out of the + pane — `min-width: 0` is what lets it ellipsize (§12b rule 3). */ +.sidebar-root-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sidebar-root-btn { + flex: none; + display: flex; + align-items: center; + justify-content: center; + min-width: var(--target-sm); + min-height: var(--target-sm); + padding: 0; + border: 0; + border-radius: var(--r-sm); + background: none; + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: var(--text-md); + line-height: 1; +} + +.sidebar-root-btn:hover { + background: var(--hover-bg); + color: var(--fg); +} + +/* ---- Inline name field (new note / new folder / rename) ------------------- */ +.sidebar-edit { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-1) 0; + min-width: 0; +} + +.sidebar-edit-input { + min-height: var(--target-sm); + width: 100%; + min-width: 0; + padding: 0 var(--sp-1); + border: 1px solid var(--accent); + border-radius: var(--r-sm); + background: var(--chrome-raised); + color: var(--fg); + font: inherit; +} + +/* The message the backend refused the name with. `anywhere` because it can + quote a name the user typed, which may be one long unbroken string (§12b + rule 4). */ +.sidebar-edit-error { + font-size: var(--text-xs); + color: var(--danger); + overflow-wrap: anywhere; +} + +/* ---- Context menu -------------------------------------------------------- */ +.context-menu { + position: fixed; + z-index: var(--z-dialog); + min-width: 10rem; + padding: var(--sp-1); + border: 1px solid var(--border-strong); + border-radius: var(--r-md); + background: var(--chrome-raised); + box-shadow: var(--shadow-panel); + font-size: var(--text-sm); +} + +.context-menu-item { + display: flex; + align-items: center; + width: 100%; + min-height: var(--target); + padding: 0 var(--sp-2); + border: 0; + border-radius: var(--r-sm); + background: none; + color: var(--fg); + cursor: pointer; + font: inherit; + text-align: left; + white-space: nowrap; +} + +.context-menu-item:hover:not(:disabled), +.context-menu-item:focus-visible { + background: var(--hover-bg); +} + +.context-menu-item[data-danger="true"] { + color: var(--danger); +} + +.context-menu-item:disabled { + color: var(--dim); + cursor: default; +} + +.context-menu-sep { + height: 1px; + margin: var(--sp-1) 0; + background: var(--border); +} + #sidebar .tree { list-style: none; margin: 0; @@ -1057,6 +1172,23 @@ body { border-top: 1px solid var(--border); } +/* The inline action a status message can carry (Undo, after a delete). Styled + as a link rather than a button: it sits inside a sentence, and the status bar + is 1.75rem tall — a bordered control would not fit without growing it. */ +.status-action { + padding: 0; + border: 0; + background: none; + color: var(--accent); + cursor: pointer; + font: inherit; + text-decoration: underline; +} + +.status-action:hover { + color: var(--fg); +} + .status-right { display: flex; align-items: center; diff --git a/src/ui/contextmenu.ts b/src/ui/contextmenu.ts new file mode 100644 index 0000000..f3c1cd9 --- /dev/null +++ b/src/ui/contextmenu.ts @@ -0,0 +1,198 @@ +// A small in-app context menu (ROADMAP Movement II.12, `feat/sidebar-file-ops`). +// +// **Why not a native menu.** `menu.rs` can build one, and the app menu already +// does — but a native popup cannot be driven by the headless gates, and native +// dialogs are the one thing documented to hang the app on the Linux dev box +// (`docs/ON-DEVICE-VERIFICATION.md`). A DOM menu is testable in jsdom, renders +// identically in both webview engines, and keeps the file operations reachable +// on the box where they are actually developed. +// +// Exactly one menu exists at a time: opening a second closes the first. That is +// enforced here rather than by callers, because a stray menu left behind would +// keep a document-level dismiss listener alive and swallow the next click. + +/** A selectable row. `danger` styles a destructive action (delete). */ +export interface MenuItem { + label: string; + onSelect(): void; + danger?: boolean; + /** Rendered but unselectable, with the reason as its tooltip. */ + disabled?: boolean; + title?: string; +} + +/** A rule between groups. Never focusable — it is decoration, not a row. */ +export type MenuEntry = MenuItem | "separator"; + +export interface ContextMenuOptions { + /** Viewport coordinates, normally from the triggering pointer event. */ + x: number; + y: number; + entries: MenuEntry[]; + /** Where to mount. Defaults to `document.body`. */ + host?: HTMLElement; +} + +let openMenu: HTMLElement | null = null; +let dismiss: (() => void) | null = null; +/** + * Where focus was before the menu opened. + * + * Restored on close, and it matters more here than in most menus: the sidebar + * row that opened this is the thing a keyboard user was navigating, and + * dropping focus to `` would send them back to the top of the document + * every time they pressed Escape. + */ +let returnFocusTo: HTMLElement | null = null; + +/** Close any open context menu. Safe to call when none is open. */ +export function closeContextMenu(): void { + dismiss?.(); +} + +function isItem(entry: MenuEntry): entry is MenuItem { + return entry !== "separator"; +} + +/** + * Open a context menu at `(x, y)`. + * + * Selecting an item closes the menu **before** running its handler: handlers + * open inline editors and move focus, and a menu still on screen would fight + * them for it. + */ +export function openContextMenu(opts: ContextMenuOptions): void { + closeContextMenu(); + + const host = opts.host ?? document.body; + const active = document.activeElement; + returnFocusTo = active instanceof HTMLElement ? active : null; + + const menu = document.createElement("div"); + menu.className = "context-menu"; + menu.setAttribute("role", "menu"); + menu.style.left = `${Math.max(0, opts.x)}px`; + menu.style.top = `${Math.max(0, opts.y)}px`; + + const buttons: HTMLButtonElement[] = []; + for (const entry of opts.entries) { + if (!isItem(entry)) { + const rule = document.createElement("div"); + rule.className = "context-menu-sep"; + // Decorative: exposing it as a menu row would make screen readers count + // separators among the choices. + rule.setAttribute("role", "none"); + menu.append(rule); + continue; + } + + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "context-menu-item"; + btn.setAttribute("role", "menuitem"); + btn.textContent = entry.label; + if (entry.danger) btn.dataset.danger = "true"; + if (entry.title) btn.title = entry.title; + if (entry.disabled) { + btn.disabled = true; + } else { + btn.addEventListener("click", () => { + close(); + entry.onSelect(); + }); + buttons.push(btn); + } + menu.append(btn); + } + + function close(restoreFocus = true): void { + document.removeEventListener("pointerdown", onPointerDown, true); + document.removeEventListener("keydown", onKeyDown, true); + window.removeEventListener("resize", onDismissEvent); + window.removeEventListener("blur", onDismissEvent); + menu.remove(); + if (openMenu === menu) { + openMenu = null; + dismiss = null; + } + // Only when the menu still owned focus. A pointer dismiss has already moved + // focus somewhere the user chose, and yanking it back would undo that. + if (restoreFocus && returnFocusTo?.isConnected) returnFocusTo.focus(); + returnFocusTo = null; + } + + function onPointerDown(e: PointerEvent): void { + if (e.target instanceof Node && menu.contains(e.target)) return; + close(false); + } + + function onDismissEvent(): void { + close(false); + } + + function onKeyDown(e: KeyboardEvent): void { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + close(); + return; + } + // Tab out of a menu means "I'm done here" everywhere else; closing without + // swallowing the key lets focus move on naturally. + if (e.key === "Tab") { + close(false); + return; + } + if (buttons.length === 0) return; + + const current = buttons.indexOf(document.activeElement as HTMLButtonElement); + let next: number | null = null; + if (e.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % buttons.length; + else if (e.key === "ArrowUp") + next = current < 0 ? buttons.length - 1 : (current - 1 + buttons.length) % buttons.length; + else if (e.key === "Home") next = 0; + else if (e.key === "End") next = buttons.length - 1; + + if (next !== null) { + e.preventDefault(); + e.stopPropagation(); + buttons[next]?.focus(); + } + } + + host.append(menu); + openMenu = menu; + dismiss = () => close(); + + // Keep the menu on screen. Measured after mounting because the width is not + // known until the labels are laid out; `getBoundingClientRect` reports zeroes + // under jsdom, and the clamp is written to be a no-op in that case rather + // than to produce a negative offset. + const rect = menu.getBoundingClientRect(); + if (rect.width > 0 && opts.x + rect.width > window.innerWidth) { + menu.style.left = `${Math.max(0, window.innerWidth - rect.width)}px`; + } + if (rect.height > 0 && opts.y + rect.height > window.innerHeight) { + menu.style.top = `${Math.max(0, window.innerHeight - rect.height)}px`; + } + + // Capture phase on the document pair: the sidebar, the editor and the global + // keymap all listen at the document level, and an open menu must take the key + // or the click before they act on it. + document.addEventListener("pointerdown", onPointerDown, true); + document.addEventListener("keydown", onKeyDown, true); + // **Not** capture on the window pair, and `blur` is why. `blur` does not + // bubble, but a capturing window listener still sees every element's blur on + // the way down — so the menu would dismiss itself the instant it focused its + // own first item, taking whatever the user was about to click with it. Without + // capture this is what it reads as: the *window* losing focus. + window.addEventListener("resize", onDismissEvent); + window.addEventListener("blur", onDismissEvent); + + buttons[0]?.focus(); +} + +/** Whether a context menu is currently open (for tests and guards). */ +export function isContextMenuOpen(): boolean { + return openMenu !== null; +} diff --git a/src/ui/sidebar.ts b/src/ui/sidebar.ts index 32a98ba..79364c0 100644 --- a/src/ui/sidebar.ts +++ b/src/ui/sidebar.ts @@ -1,8 +1,25 @@ // Workspace file tree (CLAUDE.md §4). Renders the FileNode[] from `open_folder` // as a collapsible tree; clicking a markdown file asks the controller to open // it. Folders use native
for zero-JS collapse. +// +// File operations (ROADMAP Movement II.12) live here as *UX only*: the context +// menu, the inline name field, and where an error message lands. Every rule that +// decides whether an operation is allowed — name validation, vault containment, +// refusing to clobber — is in `crates/fileops`, and every disk write is a Rust +// command (§10). Nothing in this file may be the only thing standing between a +// bad name and the filesystem. +import { openContextMenu, type MenuEntry } from "./contextmenu"; import type { FileNode } from "../ipc"; +/** + * Callbacks the controller supplies. The file-operation ones are **optional**, + * and the menu offers only what is wired — the same rule the empty state already + * follows: a dead control that looks live is worse than an absent one. + * + * The operation callbacks may reject. The sidebar catches, shows the message + * beside the still-open name field, and lets the user correct it — so a rejected + * name costs a keystroke rather than the whole interaction. + */ export interface SidebarCallbacks { onOpenFile(path: string): void; /** @@ -13,68 +30,182 @@ export interface SidebarCallbacks { * is worse than a sentence. */ onOpenFolder?(): void; + /** Create an empty note called `name` in `dir`. */ + onCreateNote?(dir: string, name: string): Promise; + /** Create a folder called `name` in `parent`. */ + onCreateFolder?(parent: string, name: string): Promise; + /** Rename the entry at `path` to `newName`, in the same parent. */ + onRename?(path: string, newName: string): Promise; + /** Delete (soft, to trash) the entry at `path`. */ + onDelete?(path: string, isDir: boolean): void; + /** A note name not already taken in `dir`, to pre-fill the field with. */ + suggestName?(dir: string): Promise; +} + +/** What the open inline field is for. */ +type EditKind = "note" | "folder" | "rename"; + +interface EditState { + kind: EditKind; + /** Container the new entry goes in (create), or the entry's parent (rename). */ + dir: string; + /** The entry being renamed. Absent for creates. */ + target?: { path: string; name: string }; + /** Current field text, so a re-open after a rejected name keeps it. */ + value: string; + error: string | null; } export class Sidebar { + private rootName: string | null = null; + private rootPath: string | null = null; + private tree: FileNode[] = []; + private edit: EditState | null = null; + /** + * A tree that arrived while a name was being typed. + * + * The controller refreshes on every watcher event, and a sync daemon touching + * an unrelated file mid-rename would otherwise re-render the field out from + * under the user, losing what they had typed. Deferring is safe because the + * delay is bounded by the edit: confirming or cancelling flushes immediately, + * and the operation itself triggers a fresh refresh straight after. + */ + private pendingTree: { rootName: string | null; rootPath: string | null; tree: FileNode[] } | null = + null; + private activePath: string | null = null; + constructor( private readonly container: HTMLElement, private readonly cb: SidebarCallbacks, ) {} - /** Render a workspace, or an empty-state hint when no folder is open. */ - setRoot(rootName: string | null, tree: FileNode[]): void { - this.container.replaceChildren(); + /** + * Render a workspace, or an empty-state hint when no folder is open. + * + * `rootPath` is what the file operations need — a new note has to be created + * *somewhere*, and the root heading is a legitimate target. + */ + setRoot(rootName: string | null, tree: FileNode[], rootPath: string | null = null): void { + if (this.edit) { + this.pendingTree = { rootName, rootPath, tree }; + return; + } + this.rootName = rootName; + this.rootPath = rootPath; + this.tree = tree; + this.render(); + } - if (rootName === null) { - // An empty state that only *names* the emptiness leaves the user to find - // the menu; the fix for "no folder open" belongs next to the message. - const wrap = document.createElement("div"); - wrap.className = "sidebar-empty"; - - const hint = document.createElement("p"); - hint.className = "sidebar-empty-text"; - hint.textContent = "No folder open."; - wrap.append(hint); - - if (this.cb.onOpenFolder) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "sidebar-empty-btn"; - btn.textContent = "Open Folder…"; - btn.addEventListener("click", () => this.cb.onOpenFolder?.()); - wrap.append(btn); - - const note = document.createElement("p"); - note.className = "sidebar-empty-note"; - // Worth saying once, here: the folder is the user's, not Toril's. It is - // the single most load-bearing promise in §1 and the empty state is the - // moment someone is deciding whether to point it at a real vault. - note.textContent = - "Pick any folder of notes — including an Obsidian vault. Files stay plain Markdown where they are."; - wrap.append(note); - } + /** Highlight the currently active file by path (no-op if not in the tree). */ + setActivePath(path: string | null): void { + this.activePath = path; + this.applyActive(); + } + + /** Whether an inline name field is currently open (renders are deferred while it is). */ + isEditing(): boolean { + return this.edit !== null; + } + + private applyActive(): void { + for (const el of this.container.querySelectorAll(".file-entry")) { + el.dataset.active = String(el.dataset.path === this.activePath); + } + } - this.container.append(wrap); + private render(): void { + this.container.replaceChildren(); + + if (this.rootName === null) { + this.container.append(this.renderEmptyState()); return; } + this.container.append(this.renderHeading(this.rootName)); + this.container.append(this.renderNodes(this.tree, this.rootPath)); + this.mountEditor(); + this.applyActive(); + } + + private renderEmptyState(): HTMLElement { + // An empty state that only *names* the emptiness leaves the user to find + // the menu; the fix for "no folder open" belongs next to the message. + const wrap = document.createElement("div"); + wrap.className = "sidebar-empty"; + + const hint = document.createElement("p"); + hint.className = "sidebar-empty-text"; + hint.textContent = "No folder open."; + wrap.append(hint); + + if (this.cb.onOpenFolder) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "sidebar-empty-btn"; + btn.textContent = "Open Folder…"; + btn.addEventListener("click", () => this.cb.onOpenFolder?.()); + wrap.append(btn); + + const note = document.createElement("p"); + note.className = "sidebar-empty-note"; + // Worth saying once, here: the folder is the user's, not Toril's. It is + // the single most load-bearing promise in §1 and the empty state is the + // moment someone is deciding whether to point it at a real vault. + note.textContent = + "Pick any folder of notes — including an Obsidian vault. Files stay plain Markdown where they are."; + wrap.append(note); + } + return wrap; + } + + private renderHeading(rootName: string): HTMLElement { const heading = document.createElement("div"); heading.className = "sidebar-root"; - heading.textContent = rootName; - this.container.append(heading); - this.container.append(this.renderNodes(tree)); - } - /** Highlight the currently active file by path (no-op if not in the tree). */ - setActivePath(path: string | null): void { - for (const el of this.container.querySelectorAll(".file-entry")) { - el.dataset.active = String(el.dataset.path === path); + const label = document.createElement("span"); + label.className = "sidebar-root-name"; + label.textContent = rootName; + heading.append(label); + + const root = this.rootPath; + if (root !== null) { + // Buttons as well as the context menu: right-click is invisible, and a + // brand-new note is the one operation someone needs on their first minute + // in a folder. + if (this.cb.onCreateNote) { + heading.append( + this.iconButton("New note", "+", () => this.beginEdit({ kind: "note", dir: root })), + ); + } + if (this.cb.onCreateFolder) { + heading.append( + this.iconButton("New folder", "🗀", () => this.beginEdit({ kind: "folder", dir: root })), + ); + } + heading.addEventListener("contextmenu", (e) => this.openMenuForFolder(e, root, null)); } + return heading; + } + + private iconButton(label: string, glyph: string, onClick: () => void): HTMLButtonElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "sidebar-root-btn"; + btn.title = label; + btn.setAttribute("aria-label", label); + // The glyph is decorative — the accessible name comes from aria-label, and + // announcing "plus sign" after it would be noise. + btn.textContent = glyph; + btn.addEventListener("click", onClick); + return btn; } - private renderNodes(nodes: FileNode[]): HTMLElement { + private renderNodes(nodes: FileNode[], dir: string | null): HTMLElement { const ul = document.createElement("ul"); ul.className = "tree"; + // Marks which folder this list belongs to, so an inline "new note" field can + // be mounted into the right one after a render. + if (dir !== null) ul.dataset.dir = dir; for (const node of nodes) { ul.append(node.is_dir ? this.renderDir(node) : this.renderFile(node)); } @@ -87,7 +218,9 @@ export class Sidebar { details.open = true; const summary = document.createElement("summary"); summary.textContent = node.name; - details.append(summary, this.renderNodes(node.children)); + summary.dataset.path = node.path; + summary.addEventListener("contextmenu", (e) => this.openMenuForFolder(e, node.path, node)); + details.append(summary, this.renderNodes(node.children, node.path)); li.append(details); return li; } @@ -99,7 +232,323 @@ export class Sidebar { entry.dataset.path = node.path; entry.textContent = node.name; entry.addEventListener("click", () => this.cb.onOpenFile(node.path)); + entry.addEventListener("contextmenu", (e) => this.openMenuForFile(e, node)); li.append(entry); return li; } + + // ---- context menus ------------------------------------------------------- + + /** + * Menu coordinates. A `contextmenu` event raised from the keyboard (Shift+F10, + * the menu key) carries no pointer position and reports 0,0 — anchoring to the + * row's own box puts the menu where the user is looking instead of the corner + * of the window. + */ + private menuPosition(e: MouseEvent): { x: number; y: number } { + if (e.clientX !== 0 || e.clientY !== 0) return { x: e.clientX, y: e.clientY }; + const target = e.currentTarget; + if (target instanceof HTMLElement) { + const rect = target.getBoundingClientRect(); + return { x: rect.left, y: rect.bottom }; + } + return { x: 0, y: 0 }; + } + + private openMenuForFile(e: MouseEvent, node: FileNode): void { + e.preventDefault(); + e.stopPropagation(); + const dir = parentDir(node.path); + const entries: MenuEntry[] = [ + { label: "Open", onSelect: () => this.cb.onOpenFile(node.path) }, + ]; + if (this.cb.onRename) { + entries.push({ + label: "Rename…", + onSelect: () => + this.beginEdit({ kind: "rename", dir, target: { path: node.path, name: node.name } }), + }); + } + if (this.cb.onDelete) { + entries.push("separator", { + label: "Delete", + danger: true, + title: "Move to the workspace .trash folder — this can be undone", + onSelect: () => this.cb.onDelete?.(node.path, false), + }); + } + openContextMenu({ ...this.menuPosition(e), entries }); + } + + /** `node` is null for the workspace root, which can be added to but not renamed. */ + private openMenuForFolder(e: MouseEvent, path: string, node: FileNode | null): void { + e.preventDefault(); + e.stopPropagation(); + const entries: MenuEntry[] = []; + if (this.cb.onCreateNote) { + entries.push({ + label: "New Note…", + onSelect: () => this.beginEdit({ kind: "note", dir: path }), + }); + } + if (this.cb.onCreateFolder) { + entries.push({ + label: "New Folder…", + onSelect: () => this.beginEdit({ kind: "folder", dir: path }), + }); + } + if (node) { + if (this.cb.onRename) { + entries.push("separator", { + label: "Rename…", + onSelect: () => + this.beginEdit({ + kind: "rename", + dir: parentDir(node.path), + target: { path: node.path, name: node.name }, + }), + }); + } + if (this.cb.onDelete) { + if (!this.cb.onRename) entries.push("separator"); + entries.push({ + label: "Delete", + danger: true, + title: "Move the folder and everything in it to .trash — this can be undone", + onSelect: () => this.cb.onDelete?.(node.path, true), + }); + } + } + if (entries.length === 0) return; + openContextMenu({ ...this.menuPosition(e), entries }); + } + + // ---- the inline name field ---------------------------------------------- + + private beginEdit(edit: Omit & { value?: string }): void { + this.edit = { + ...edit, + value: edit.value ?? edit.target?.name ?? "", + error: null, + }; + this.render(); + + // A create field starts empty and is filled in asynchronously, so the user + // can begin typing immediately and a slow suggestion never overwrites what + // they typed — checked against the field's *current* value, not the state's. + if (edit.kind === "note" && this.cb.suggestName && !edit.value) { + const dir = edit.dir; + void this.cb.suggestName(dir).then((name) => { + const input = this.fieldInput(); + if (!this.edit || this.edit.kind !== "note" || this.edit.dir !== dir) return; + if (!input || input.value !== "") return; + input.value = name; + this.edit.value = name; + selectStem(input); + }); + } + } + + private endEdit(): void { + this.edit = null; + const pending = this.pendingTree; + this.pendingTree = null; + if (pending) { + this.rootName = pending.rootName; + this.rootPath = pending.rootPath; + this.tree = pending.tree; + } + this.render(); + } + + private fieldInput(): HTMLInputElement | null { + return this.container.querySelector(".sidebar-edit-input"); + } + + /** + * Put the field into the rendered tree. + * + * Called after every render so the field survives one — a create is mounted + * into its folder's list, a rename replaces the row it renames. If the anchor + * is missing (the folder disappeared from under us) the edit is abandoned + * rather than mounted somewhere arbitrary. + */ + private mountEditor(): void { + const edit = this.edit; + if (!edit) return; + + const field = this.buildField(edit); + + if (edit.kind === "rename" && edit.target) { + const row = + this.container.querySelector(`.file-entry[data-path="${cssEscape(edit.target.path)}"]`) ?? + this.container.querySelector(`summary[data-path="${cssEscape(edit.target.path)}"]`); + if (!row) { + this.edit = null; + return; + } + row.replaceWith(field); + } else { + const list = this.container.querySelector( + `ul[data-dir="${cssEscape(edit.dir)}"]`, + ); + if (!list) { + this.edit = null; + return; + } + const li = document.createElement("li"); + li.append(field); + list.prepend(li); + // A collapsed folder would hide the field the user is typing into. + const details = list.closest("details"); + if (details) details.open = true; + } + + const input = field.querySelector("input"); + input?.focus(); + if (input) selectStem(input); + } + + private buildField(edit: EditState): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "sidebar-edit"; + wrap.dataset.kind = edit.kind; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "sidebar-edit-input"; + input.value = edit.value; + input.spellcheck = false; + input.setAttribute( + "aria-label", + edit.kind === "rename" ? `Rename ${edit.target?.name ?? ""}` : `Name of the new ${edit.kind}`, + ); + input.addEventListener("input", () => { + if (this.edit) this.edit.value = input.value; + }); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + void this.commit(input.value); + } else if (e.key === "Escape") { + e.preventDefault(); + // Escape must not also reach the global keymap (which closes panes). + e.stopPropagation(); + this.endEdit(); + } + }); + // Blur **cancels** rather than commits. Committing on blur is how a + // half-typed name becomes a real rename because someone clicked away; the + // cost of cancelling is retyping, which is recoverable, and this direction + // never touches the filesystem on an action the user did not confirm. + // + // `isConnected` distinguishes the user leaving the field from the field + // being replaced by a re-render — showing an error rebuilds it, and treating + // that as a cancel would wipe the message the moment it appeared. + input.addEventListener("blur", () => { + if (!input.isConnected) return; + if (this.edit && !this.busy) this.endEdit(); + }); + wrap.append(input); + + if (edit.error) { + const err = document.createElement("div"); + err.className = "sidebar-edit-error"; + err.setAttribute("role", "alert"); + err.textContent = edit.error; + wrap.append(err); + } + return wrap; + } + + /** + * True while an operation is in flight. + * + * Guards two things: the blur handler (the field loses focus while the awaited + * command runs, and that must not read as a cancel), and double submission + * from a second Enter before the first resolves — which for a create would be + * a second create attempt against a path the first one is making. + */ + private busy = false; + + private async commit(value: string): Promise { + const edit = this.edit; + if (!edit || this.busy) return; + + const name = value.trim(); + if (name === "") { + this.showError("Enter a name."); + return; + } + // A rename to the same name is a no-op the backend also accepts; short- + // circuiting here keeps it from showing up as a save in version history. + if (edit.kind === "rename" && name === edit.target?.name) { + this.endEdit(); + return; + } + + this.busy = true; + try { + if (edit.kind === "note") await this.cb.onCreateNote?.(edit.dir, name); + else if (edit.kind === "folder") await this.cb.onCreateFolder?.(edit.dir, name); + else if (edit.target) await this.cb.onRename?.(edit.target.path, name); + this.busy = false; + this.endEdit(); + } catch (e) { + this.busy = false; + this.showError(messageOf(e)); + } + } + + /** Re-render the field with an error beside it, keeping what was typed. */ + private showError(error: string): void { + if (!this.edit) return; + this.edit.error = error; + this.render(); + } +} + +/** The containing directory of `path`, using whichever separator it carries. */ +function parentDir(path: string): string { + const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return cut > 0 ? path.slice(0, cut) : path; +} + +/** + * Select the name but not the extension, so typing replaces `note` in `note.md`. + * + * The convention every file manager uses, and it matters more here: the field is + * pre-filled with a full file name, and a plain select-all means renaming + * usually starts by retyping `.md`. + */ +function selectStem(input: HTMLInputElement): void { + const dot = input.value.lastIndexOf("."); + // `setSelectionRange` is not implemented for every input type in every engine; + // a failure here costs a nicety, never the rename. + try { + if (dot > 0) input.setSelectionRange(0, dot); + else input.select(); + } catch { + /* selection is cosmetic */ + } +} + +/** + * Quote a path for use inside an attribute selector. + * + * Paths are arbitrary user data and Windows paths are full of backslashes, which + * a selector reads as escapes. `CSS.escape` is the correct tool and exists in + * both webview engines; the fallback keeps this working under a bare jsdom that + * has not implemented it. + */ +function cssEscape(value: string): string { + if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(value); + return value.replace(/["\\]/g, "\\$&"); +} + +/** The user-facing text of a rejection, which from Tauri is a plain string. */ +function messageOf(e: unknown): string { + if (typeof e === "string") return e; + if (e instanceof Error) return e.message; + return String(e); } diff --git a/tests/contextmenu.test.ts b/tests/contextmenu.test.ts new file mode 100644 index 0000000..8e9f376 --- /dev/null +++ b/tests/contextmenu.test.ts @@ -0,0 +1,188 @@ +// Context-menu gate (CLAUDE.md §8, ROADMAP Movement II.12). +// +// The menu is the entry point to every destructive file operation, so what is +// pinned here is the behaviour that decides whether the *right* action runs: +// only one menu at a time, dismissal actually removing its document listeners, +// and the menu closing before its handler runs (the handlers open an inline +// field and take focus). +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + closeContextMenu, + isContextMenuOpen, + openContextMenu, + type MenuEntry, +} from "../src/ui/contextmenu"; + +afterEach(() => { + closeContextMenu(); + document.body.replaceChildren(); +}); + +function items(): HTMLButtonElement[] { + return Array.from(document.querySelectorAll(".context-menu-item")); +} + +function press(key: string): void { + document.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); +} + +function open(entries: MenuEntry[]): void { + openContextMenu({ x: 10, y: 10, entries }); +} + +describe("openContextMenu", () => { + it("renders one row per item and skips separators as choices", () => { + open([ + { label: "Open", onSelect: () => {} }, + "separator", + { label: "Delete", onSelect: () => {}, danger: true }, + ]); + + expect(items().map((b) => b.textContent)).toEqual(["Open", "Delete"]); + expect(document.querySelectorAll(".context-menu-sep")).toHaveLength(1); + expect(items()[1].dataset.danger).toBe("true"); + }); + + it("opening a second menu replaces the first", () => { + open([{ label: "A", onSelect: () => {} }]); + open([{ label: "B", onSelect: () => {} }]); + + expect(document.querySelectorAll(".context-menu")).toHaveLength(1); + expect(items().map((b) => b.textContent)).toEqual(["B"]); + }); + + it("closes before running the handler, so the handler can take focus", () => { + let openWhenHandlerRan: boolean | null = null; + open([{ label: "Rename…", onSelect: () => (openWhenHandlerRan = isContextMenuOpen()) }]); + + items()[0].click(); + + expect(openWhenHandlerRan).toBe(false); + expect(isContextMenuOpen()).toBe(false); + }); + + it("Escape closes without selecting anything", () => { + const onSelect = vi.fn(); + open([{ label: "Delete", onSelect }]); + + press("Escape"); + + expect(isContextMenuOpen()).toBe(false); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("a pointer press outside closes it; one inside does not", () => { + open([{ label: "Open", onSelect: () => {} }]); + + items()[0].dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + expect(isContextMenuOpen()).toBe(true); + + document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + expect(isContextMenuOpen()).toBe(false); + }); + + /** + * The failure this pins is silent: a menu that closes but leaves its + * document-level listeners attached goes on swallowing the next Escape or + * click, and nothing about the UI shows why. + */ + it("stops handling keys once closed", () => { + const onSelect = vi.fn(); + open([{ label: "Delete", onSelect }]); + closeContextMenu(); + + press("ArrowDown"); + press("Enter"); + + expect(onSelect).not.toHaveBeenCalled(); + expect(document.querySelector(".context-menu")).toBeNull(); + }); + + it("arrow keys move focus and wrap in both directions", () => { + open([ + { label: "One", onSelect: () => {} }, + { label: "Two", onSelect: () => {} }, + { label: "Three", onSelect: () => {} }, + ]); + const [one, two, three] = items(); + + expect(document.activeElement).toBe(one); + press("ArrowDown"); + expect(document.activeElement).toBe(two); + press("ArrowUp"); + expect(document.activeElement).toBe(one); + press("ArrowUp"); // wraps to the end + expect(document.activeElement).toBe(three); + press("ArrowDown"); // wraps to the start + expect(document.activeElement).toBe(one); + press("End"); + expect(document.activeElement).toBe(three); + press("Home"); + expect(document.activeElement).toBe(one); + }); + + it("skips a disabled item in keyboard traversal and cannot select it", () => { + const onSelect = vi.fn(); + open([ + { label: "Open", onSelect: () => {} }, + { label: "Rename…", onSelect, disabled: true }, + { label: "Delete", onSelect: () => {} }, + ]); + + press("ArrowDown"); + + expect((document.activeElement as HTMLElement).textContent).toBe("Delete"); + items()[1].click(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it("restores focus to whatever opened it", () => { + const trigger = document.createElement("button"); + document.body.append(trigger); + trigger.focus(); + + open([{ label: "Open", onSelect: () => {} }]); + expect(document.activeElement).not.toBe(trigger); + + press("Escape"); + expect(document.activeElement).toBe(trigger); + }); + + it("does not yank focus back when dismissed by a click elsewhere", () => { + const trigger = document.createElement("button"); + const other = document.createElement("input"); + document.body.append(trigger, other); + trigger.focus(); + + open([{ label: "Open", onSelect: () => {} }]); + other.focus(); + other.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + + expect(document.activeElement).toBe(other); + }); + + /** + * Regression: the dismiss-on-window-blur listener was registered with + * `capture: true`, and a capturing window listener sees every *element's* + * blur too — so opening a menu while anything was focused closed it again + * the instant it focused its own first item. + */ + it("survives opening while another element has focus", () => { + const trigger = document.createElement("button"); + document.body.append(trigger); + trigger.focus(); + + open([{ label: "Open", onSelect: () => {} }]); + + expect(isContextMenuOpen()).toBe(true); + expect(items()).toHaveLength(1); + }); + + it("keeps a negative anchor on screen", () => { + openContextMenu({ x: -50, y: -20, entries: [{ label: "Open", onSelect: () => {} }] }); + const menu = document.querySelector(".context-menu"); + + expect(menu?.style.left).toBe("0px"); + expect(menu?.style.top).toBe("0px"); + }); +}); diff --git a/tests/sidebar.test.ts b/tests/sidebar.test.ts new file mode 100644 index 0000000..0f2a897 --- /dev/null +++ b/tests/sidebar.test.ts @@ -0,0 +1,315 @@ +// Sidebar file-operations gate (CLAUDE.md §8, ROADMAP Movement II.12). +// +// What the sidebar owns is the *interaction*, so that is what is pinned here: +// which operations the menu offers, that a rejected name keeps the field open +// with the message rather than silently doing nothing, that blur cancels rather +// than commits, and that a background refresh cannot delete what the user is +// typing. The rules the operations are judged against live in `crates/fileops` +// and are tested there; the disk itself is never touched from this file. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { closeContextMenu } from "../src/ui/contextmenu"; +import { Sidebar, type SidebarCallbacks } from "../src/ui/sidebar"; +import type { FileNode } from "../src/ipc"; + +const ROOT = "/vault"; + +function file(name: string, dir = ROOT): FileNode { + return { name, path: `${dir}/${name}`, is_dir: false, children: [] }; +} + +function folder(name: string, children: FileNode[], dir = ROOT): FileNode { + return { name, path: `${dir}/${name}`, is_dir: true, children }; +} + +function mount(cb: Partial = {}, tree: FileNode[] = [file("note.md")]) { + const container = document.createElement("div"); + document.body.append(container); + const sidebar = new Sidebar(container, { onOpenFile: () => {}, ...cb }); + sidebar.setRoot("vault", tree, ROOT); + return { sidebar, container }; +} + +function rightClick(el: Element): void { + el.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, clientX: 5, clientY: 5 })); +} + +function menuLabels(): string[] { + return Array.from(document.querySelectorAll(".context-menu-item")).map( + (b) => b.textContent ?? "", + ); +} + +function clickMenu(label: string): void { + const item = Array.from( + document.querySelectorAll(".context-menu-item"), + ).find((b) => b.textContent === label); + if (!item) throw new Error(`no menu item "${label}" (have: ${menuLabels().join(", ")})`); + item.click(); +} + +function field(container: HTMLElement): HTMLInputElement { + const input = container.querySelector(".sidebar-edit-input"); + if (!input) throw new Error("no name field is open"); + return input; +} + +function typeAndConfirm(input: HTMLInputElement, value: string): void { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); +} + +/** Let the awaited callback inside `commit` settle. */ +const settle = (): Promise => new Promise((r) => setTimeout(r, 0)); + +afterEach(() => { + closeContextMenu(); + document.body.replaceChildren(); +}); + +describe("the tree", () => { + it("renders files and folders, and opens a file on click", () => { + const onOpenFile = vi.fn(); + const { container } = mount({ onOpenFile }, [folder("sub", [file("deep.md", "/vault/sub")])]); + + const entry = container.querySelector(".file-entry"); + expect(entry?.textContent).toBe("deep.md"); + entry?.click(); + expect(onOpenFile).toHaveBeenCalledWith("/vault/sub/deep.md"); + }); +}); + +describe("the context menu", () => { + /** + * The empty state already follows this rule for Open Folder: a control that + * looks live and does nothing is worse than an absent one. It matters more + * here, because the absent handler would be Delete. + */ + it("offers only the operations that are wired", () => { + const { container } = mount({}); + rightClick(container.querySelector(".file-entry")!); + expect(menuLabels()).toEqual(["Open"]); + + closeContextMenu(); + document.body.replaceChildren(); + + const wired = mount({ onRename: vi.fn(), onDelete: vi.fn() }); + rightClick(wired.container.querySelector(".file-entry")!); + expect(menuLabels()).toEqual(["Open", "Rename…", "Delete"]); + }); + + it("offers creation on a folder, and on the workspace root", () => { + const { container } = mount( + { onCreateNote: vi.fn(), onCreateFolder: vi.fn(), onRename: vi.fn(), onDelete: vi.fn() }, + [folder("sub", [file("deep.md", "/vault/sub")])], + ); + + rightClick(container.querySelector("summary")!); + expect(menuLabels()).toEqual(["New Note…", "New Folder…", "Rename…", "Delete"]); + + closeContextMenu(); + rightClick(container.querySelector(".sidebar-root")!); + // The root is the folder the user opened: it can be added to, but Toril has + // no business renaming or trashing it from inside the tree. + expect(menuLabels()).toEqual(["New Note…", "New Folder…"]); + }); + + it("marks Delete as destructive", () => { + const { container } = mount({ onDelete: vi.fn() }); + rightClick(container.querySelector(".file-entry")!); + + const del = Array.from( + document.querySelectorAll(".context-menu-item"), + ).find((b) => b.textContent === "Delete"); + expect(del?.dataset.danger).toBe("true"); + }); + + it("passes is_dir so the caller can find every tab under a folder", () => { + const onDelete = vi.fn(); + const { container } = mount({ onDelete }, [folder("sub", [file("deep.md", "/vault/sub")])]); + + rightClick(container.querySelector("summary")!); + clickMenu("Delete"); + + expect(onDelete).toHaveBeenCalledWith("/vault/sub", true); + }); +}); + +describe("creating", () => { + it("creates a note in the folder that was right-clicked", async () => { + const onCreateNote = vi.fn().mockResolvedValue(undefined); + const { container } = mount({ onCreateNote }, [ + folder("sub", [file("deep.md", "/vault/sub")]), + ]); + + rightClick(container.querySelector("summary")!); + clickMenu("New Note…"); + typeAndConfirm(field(container), "Ideas"); + await settle(); + + expect(onCreateNote).toHaveBeenCalledWith("/vault/sub", "Ideas"); + expect(container.querySelector(".sidebar-edit-input")).toBeNull(); + }); + + it("creates at the root from the header button", async () => { + const onCreateNote = vi.fn().mockResolvedValue(undefined); + const { container } = mount({ onCreateNote }); + + container.querySelector(".sidebar-root-btn")?.click(); + typeAndConfirm(field(container), "Ideas"); + await settle(); + + expect(onCreateNote).toHaveBeenCalledWith(ROOT, "Ideas"); + }); + + it("pre-fills a suggested name without overwriting what is already typed", async () => { + let release!: (name: string) => void; + const suggestName = vi.fn(() => new Promise((r) => (release = r))); + const { container } = mount({ onCreateNote: vi.fn(), suggestName }); + + container.querySelector(".sidebar-root-btn")?.click(); + const input = field(container); + // The user does not wait for the round trip. + input.value = "Ideas"; + input.dispatchEvent(new Event("input", { bubbles: true })); + release("Untitled 2.md"); + await settle(); + + expect(input.value).toBe("Ideas"); + }); + + it("fills in the suggestion when the field is still untouched", async () => { + const suggestName = vi.fn().mockResolvedValue("Untitled 2.md"); + const { container } = mount({ onCreateNote: vi.fn(), suggestName }); + + container.querySelector(".sidebar-root-btn")?.click(); + await settle(); + + expect(field(container).value).toBe("Untitled 2.md"); + }); + + it("refuses an empty name without calling the backend", async () => { + const onCreateNote = vi.fn(); + const { container } = mount({ onCreateNote }); + + container.querySelector(".sidebar-root-btn")?.click(); + typeAndConfirm(field(container), " "); + await settle(); + + expect(onCreateNote).not.toHaveBeenCalled(); + expect(container.querySelector(".sidebar-edit-error")?.textContent).toBe("Enter a name."); + }); +}); + +describe("renaming", () => { + it("sends the new name and closes the field", async () => { + const onRename = vi.fn().mockResolvedValue(undefined); + const { container } = mount({ onRename }); + + rightClick(container.querySelector(".file-entry")!); + clickMenu("Rename…"); + expect(field(container).value).toBe("note.md"); + + typeAndConfirm(field(container), "renamed.md"); + await settle(); + + expect(onRename).toHaveBeenCalledWith("/vault/note.md", "renamed.md"); + }); + + it("treats an unchanged name as a cancel, not a rename", async () => { + const onRename = vi.fn(); + const { container } = mount({ onRename }); + + rightClick(container.querySelector(".file-entry")!); + clickMenu("Rename…"); + typeAndConfirm(field(container), "note.md"); + await settle(); + + // A no-op rename that reached Rust would still be a write in the eyes of the + // watcher, and would show up as a change to a file nothing changed. + expect(onRename).not.toHaveBeenCalled(); + expect(container.querySelector(".sidebar-edit-input")).toBeNull(); + }); + + it("keeps the field open with the message when the backend refuses", async () => { + const onRename = vi.fn().mockRejectedValue('"CON" is a reserved name on Windows.'); + const { container } = mount({ onRename }); + + rightClick(container.querySelector(".file-entry")!); + clickMenu("Rename…"); + typeAndConfirm(field(container), "CON.md"); + await settle(); + + expect(container.querySelector(".sidebar-edit-error")?.textContent).toBe( + '"CON" is a reserved name on Windows.', + ); + // Still editable, and still holding what was typed — a rejected name should + // cost a keystroke, not the whole interaction. + expect(field(container).value).toBe("CON.md"); + }); + + it("Escape cancels without renaming", async () => { + const onRename = vi.fn(); + const { container } = mount({ onRename }); + + rightClick(container.querySelector(".file-entry")!); + clickMenu("Rename…"); + const input = field(container); + input.value = "half-typed"; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await settle(); + + expect(onRename).not.toHaveBeenCalled(); + expect(container.querySelector(".file-entry")?.textContent).toBe("note.md"); + }); + + /** + * Blur must **cancel**. Committing on blur is how a half-typed name becomes a + * real rename because the user clicked somewhere else — and unlike a cancel, + * that one touches the filesystem on an action nobody confirmed. + */ + it("blur cancels rather than committing", async () => { + const onRename = vi.fn(); + const { container } = mount({ onRename }); + + rightClick(container.querySelector(".file-entry")!); + clickMenu("Rename…"); + const input = field(container); + input.value = "half-typed"; + input.dispatchEvent(new Event("blur", { bubbles: false })); + await settle(); + + expect(onRename).not.toHaveBeenCalled(); + expect(container.querySelector(".sidebar-edit-input")).toBeNull(); + }); +}); + +describe("refreshes while a name is being typed", () => { + /** + * The controller refreshes the tree on every watcher event, so a sync daemon + * touching an unrelated file mid-rename would otherwise re-render the field + * out from under the user and lose what they had typed. + */ + it("defers the new tree until the edit ends, then applies it", async () => { + const onRename = vi.fn().mockResolvedValue(undefined); + const { sidebar, container } = mount({ onRename }); + + rightClick(container.querySelector(".file-entry")!); + clickMenu("Rename…"); + const input = field(container); + input.value = "half-typed"; + input.dispatchEvent(new Event("input", { bubbles: true })); + + sidebar.setRoot("vault", [file("note.md"), file("arrived.md")], ROOT); + + expect(sidebar.isEditing()).toBe(true); + expect(field(container).value).toBe("half-typed"); + expect(container.textContent).not.toContain("arrived.md"); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await settle(); + + expect(sidebar.isEditing()).toBe(false); + expect(container.textContent).toContain("arrived.md"); + }); +});