Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 70 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 `<app-config>/history/<hash(path)>/` — a `manifest.json` + gzip,
> sha256-addressed blobs. It lives **outside the vault** (like recovery/session, §1) so it
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Loading
Loading