Compare commits

...

24 Commits

Author SHA1 Message Date
lucaronin
ba5c165016 test: harden macos titlebar coverage 2026-04-24 08:20:30 +02:00
lucaronin
2308b269c8 docs: add ADRs for cache concurrency and git signing fallback (guard — from commits 42bb6c4, 6dbcc33) 2026-04-24 08:03:58 +02:00
lucaronin
b7f482bf27 fix: respect macos header double click action 2026-04-24 07:47:53 +02:00
lucaronin
33d7404d6c fix: keep blocknote tables editable after focus changes 2026-04-24 07:02:40 +02:00
lucaronin
0ecac2ab79 fix: allow unicode type filenames 2026-04-24 04:01:43 +02:00
lucaronin
7c9f2b0dda fix: guard editor selection churn around inline actions 2026-04-24 03:25:50 +02:00
lucaronin
24e317cfa6 fix: handle unreadable non-utf8 files gracefully 2026-04-24 03:02:03 +02:00
lucaronin
66c183fa61 fix: keep IME composition stable in editors 2026-04-24 02:50:12 +02:00
lucaronin
dacf9bb19b fix: guard note loading without active vault 2026-04-24 02:16:49 +02:00
lucaronin
3c92c200ab fix: sync onboarding vault handoff 2026-04-24 01:45:11 +02:00
lucaronin
61c3a3e21b docs: ratchet codescene thresholds 2026-04-24 01:39:28 +02:00
lucaronin
2e7c94d159 fix: register created onboarding vaults 2026-04-24 01:26:22 +02:00
lucaronin
98ade7411a fix: avoid duplicate onboarding vault writes 2026-04-24 01:23:16 +02:00
lucaronin
18776341f8 fix: persist onboarding vault selections 2026-04-24 01:08:13 +02:00
lucaronin
57bbcdbaef fix: format cache hardening helpers 2026-04-24 00:00:10 +02:00
lucaronin
42bb6c4b18 fix: harden vault cache updates 2026-04-24 00:00:10 +02:00
lucaronin
070cd3ac6d test: stabilize telemetry onboarding smoke 2026-04-23 23:11:46 +02:00
lucaronin
accc6270d3 fix: restore vault images and default folders 2026-04-23 23:11:46 +02:00
lucaronin
941f12aa1b test: clear vitest cache before coverage 2026-04-23 22:45:22 +02:00
lucaronin
4710a2d0a9 fix: reuse local smoke server by default 2026-04-23 22:27:38 +02:00
lucaronin
65a421215a fix: satisfy arrow ligature type checks 2026-04-23 22:14:20 +02:00
lucaronin
4ed0289989 Revert "test: simplify main entrypoint setup"
This reverts commit 4ad32faa7b.
2026-04-23 22:14:11 +02:00
lucaronin
4ad32faa7b test: simplify main entrypoint setup 2026-04-23 22:10:02 +02:00
lucaronin
c9623d5ca8 feat: add arrow ligatures in both editors 2026-04-23 22:06:01 +02:00
59 changed files with 3228 additions and 254 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.94
AVERAGE_THRESHOLD=9.81
AVERAGE_THRESHOLD=9.83

View File

@@ -305,7 +305,7 @@ type SidebarSelection =
- Parses dates as ISO 8601 to Unix timestamps
- Extracts relationships, outgoing links, custom properties, word count, snippet
The folder tree is narrower than the scanner: it exposes user-created folders only, while system folders such as `attachments/`, `type/`, and `views/` remain hidden from folder navigation.
The folder tree hides only the dedicated `type/` directory, since note types already have their own sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
6. Sorts by `modified_at` descending
7. Skips unparseable files with a warning log
@@ -322,7 +322,7 @@ Command-layer path access is fenced to the active vault before file operations r
3. If cache is valid and same commit → only re-parse uncommitted changed files
4. If different commit → use `git diff` to find changed files → selective re-parse
5. If no cache → full scan
6. Writes updated cache atomically (write to `.tmp`, then rename)
6. Replaces the cache with a temp-file write + rename only if a short-lived writer lock and cache fingerprint check show another scan has not already refreshed it
7. On first run, migrates any legacy `.laputa-cache.json` from inside the vault
### Frontmatter Manipulation (Rust)
@@ -524,6 +524,14 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state.
### Arrow Ligature Normalization
Typed ASCII arrow sequences are normalized consistently in both editor modes:
- Rich editor input mounts `createArrowLigaturesExtension()` (`src/components/arrowLigaturesExtension.ts`) into BlockNote and intercepts typed `beforeinput` events before ProseMirror commits the character.
- Raw editor input uses the CodeMirror `inputHandler` path in `useCodeMirror` so the same ligature rules apply while editing markdown source directly.
- Both paths delegate to the shared `resolveArrowLigatureInput()` helper in `src/utils/arrowLigatures.ts`, which prioritizes `<->` over partial matches, keeps paste literal, and lets escaped forms such as `\\->` and `\\<->` remain ASCII.
## Styling
The app uses a single light theme — the vault-based theming system was removed (see [ADR-0013](adr/0013-remove-theming-system.md)). Styling is defined in two layers:

View File

@@ -176,9 +176,9 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders only; system folders such as `attachments/`, `type/`, and `views/` remain scannable but are hidden from folder navigation. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.
@@ -382,7 +382,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
### Cache File
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan). Writes are atomic (write to `.tmp` then rename). Legacy `.laputa-cache.json` files inside the vault are auto-migrated and deleted on first run.
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
@@ -396,7 +396,7 @@ flowchart TD
D -->|Same commit| E["🟢 Cache Hit\ngit status --porcelain\n→ re-parse only uncommitted changes"]
D -->|Different commit| F["🟡 Incremental Update\ngit diff old..new --name-only\n→ selective re-parse of changed files"]
C --> G[Write cache atomically\n.tmp → rename]
C --> G[Replace cache if unchanged\nwriter lock + temp file → rename]
E --> G
F --> G
G --> H([VaultEntry list ready])

View File

@@ -0,0 +1,37 @@
---
type: ADR
id: "0077"
title: "Concurrent-safe vault cache replacement"
status: active
date: 2026-04-24
---
## Context
ADR-0014 and ADR-0024 established Tolaria's git-based persistent vault cache and moved it outside the vault directory. That cache was still being rewritten with a simple temp-file-and-rename flow.
Once Tolaria started reopening the same vault from multiple windows/processes more often, that write model became too optimistic: two scans could both build valid cache payloads from different moments in time, and the slower writer could still atomically replace a fresher cache written by another window. The cache needed cross-window safety without introducing a long-lived coordinator process or making vault open dependent on heavyweight IPC.
## Decision
**Tolaria now treats vault-cache replacement as a best-effort compare-and-swap operation instead of an unconditional atomic overwrite.**
- Each scan still builds the next cache payload in memory and writes it to a temp file first.
- Before replacing the real cache file, Tolaria acquires a short-lived lock file for that vault cache path.
- After the lock is acquired, Tolaria rechecks the on-disk cache fingerprint and only renames the temp file into place if another window/process has not already refreshed the cache.
- If the cache changed underneath the current scan, Tolaria skips the replace and keeps the newer on-disk cache.
- Stale cache-write locks are garbage-collected after a short timeout so a crashed writer does not block future refreshes.
## Options considered
- **Lock + fingerprint guarded replacement** (chosen): keeps the cache file external and file-based, avoids overwriting fresher cache state from another Tolaria window, and preserves graceful fallback to filesystem rescans. Cons: cache writes become best-effort rather than guaranteed after every scan.
- **Keep unconditional temp-file + rename**: simplest implementation, but concurrent windows can regress the cache to an older view even though each individual replace is atomic.
- **Centralized cache service or long-lived process mutex**: strongest coordination story, but too much operational complexity for a local desktop app and would create new failure modes around boot, process lifetime, and IPC.
## Consequences
- Tolaria's cache correctness model is now "latest successful guarded replace wins," not "every scan must write a cache file."
- Cache refreshes must tolerate a skipped write when another window/process already produced a fresher cache.
- Temp-file writes and renames still provide corruption resistance, but freshness is protected separately by the writer lock and fingerprint check.
- Cache-write failures remain non-fatal: Tolaria logs them and falls back to rebuilding from the filesystem when needed.
- Re-evaluate if Tolaria later needs stronger cross-process coordination than lock-file + fingerprint checks can provide.

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0078"
title: "Scoped unsigned fallback for app-managed git commits"
status: active
date: 2026-04-24
---
## Context
ADR-0021, ADR-0059, and ADR-0070 all assume Tolaria can create and advance a local git-backed vault without asking users to debug git internals first. In practice, inherited `commit.gpgsign` settings were breaking that promise: a missing or misconfigured GPG/SSH signing helper could block the initial `Initial vault setup` commit during onboarding and could also strand later app-triggered commits behind opaque signing failures.
Tolaria needed a policy that kept signed workflows intact when the user's signing setup actually works, while still ensuring app-managed git operations do not become unusable just because a desktop environment cannot reach the signing helper.
## Decision
**Tolaria uses a scoped unsigned fallback for app-managed commits instead of requiring signing to succeed unconditionally.**
- The onboarding/setup commit (`Initial vault setup`) always runs with `commit.gpgsign=false` for that single git invocation.
- Normal app-managed `git_commit` calls still honor the user's existing git signing configuration first.
- If a commit fails and Git's error matches a signing-helper failure, Tolaria retries that same app-managed commit once with signing disabled.
- Tolaria does not rewrite the user's git config and does not broaden the retry to unrelated commit failures.
## Options considered
- **Scoped unsigned fallback for app-managed commits** (chosen): keeps onboarding and local commit flows resilient while still preserving signed commits when the user's environment supports them. Cons: some Tolaria-created commits may be unsigned on machines with broken signing setups.
- **Require signing to succeed for every commit**: simplest policy, but it turns missing desktop GPG/SSH helpers into app-breaking failures during onboarding and normal use.
- **Disable signing for all Tolaria-triggered commits**: maximally robust, but it would silently bypass working signing setups and weaken users' expected git security posture.
## Consequences
- New vault creation is no longer blocked by inherited signing settings that only fail in Tolaria's app context.
- Users with healthy signing setups still get signed Tolaria commits after the first normal attempt succeeds.
- Signing-failure detection must stay narrow so Tolaria does not mask unrelated git errors behind an unsigned retry.
- Tolaria's git integration now explicitly prefers "complete the app-managed commit safely" over "preserve signing at all costs" when the signing helper is unavailable.
- Re-evaluate if Tolaria later exposes per-vault git policy controls or needs a richer user-facing explanation for when a fallback unsigned commit was used.

View File

@@ -132,3 +132,5 @@ proposed → active → superseded
| [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | Explicit external AI tool setup and least-privilege desktop scope | active |
| [0075](0075-crash-safe-note-rename-transactions.md) | Crash-safe note rename transactions | active |
| [0076](0076-note-retargeting-separates-type-and-folder-moves.md) | Note retargeting separates type changes from folder moves | active |
| [0077](0077-concurrent-safe-vault-cache-replacement.md) | Concurrent-safe vault cache replacement | active |
| [0078](0078-scoped-unsigned-fallback-for-app-managed-git-commits.md) | Scoped unsigned fallback for app-managed git commits | active |

View File

@@ -1,8 +1,6 @@
diff --git a/dist/defaultBlocks-DE5GNdJH.js b/dist/defaultBlocks-DE5GNdJH.js
index b2761001278486a8b2ac1d10c82420b4994e96d9..fbf4f2f450ed1351d64adeb16263ceacf9ee393c 100644
--- a/dist/defaultBlocks-DE5GNdJH.js
+++ b/dist/defaultBlocks-DE5GNdJH.js
@@ -2761,7 +2761,7 @@ const B = new Ze("SuggestionMenuPlugin"), _n = k(({ editor: e }) => {
@@ -2761,7 +2761,7 @@
if (a === s) {
const c = r.state.doc;
for (const l of t) {
@@ -11,10 +9,9 @@ index b2761001278486a8b2ac1d10c82420b4994e96d9..fbf4f2f450ed1351d64adeb16263ceac
if (l === u)
return r.dispatch(r.state.tr.insertText(i)), r.dispatch(
r.state.tr.setMeta(B, {
diff --git a/src/editor/managers/ExtensionManager/extensions.ts b/src/editor/managers/ExtensionManager/extensions.ts
--- a/src/editor/managers/ExtensionManager/extensions.ts
+++ b/src/editor/managers/ExtensionManager/extensions.ts
@@ -84,7 +84,8 @@ export function getDefaultTiptapExtensions(
@@ -84,7 +84,8 @@
}).configure({
defaultProtocol: DEFAULT_LINK_PROTOCOL,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
@@ -24,10 +21,9 @@ diff --git a/src/editor/managers/ExtensionManager/extensions.ts b/src/editor/man
}),
...(Object.values(editor.schema.styleSpecs).map((styleSpec) => {
return styleSpec.implementation.mark.configure({
diff --git a/dist/blocknote.js b/dist/blocknote.js
--- a/dist/blocknote.js
+++ b/dist/blocknote.js
@@ -2037,7 +2037,9 @@ const de = () => {
@@ -2037,7 +2037,9 @@
]
})
);
@@ -38,8 +34,7 @@ diff --git a/dist/blocknote.js b/dist/blocknote.js
function mn(o, e) {
const t = [
I.ClipboardTextSerializer,
@@ -2063,8 +2065,9 @@ function mn(o, e) {
inclusive: !1
@@ -2062,7 +2064,8 @@
}).configure({
defaultProtocol: Ct,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
@@ -49,7 +44,7 @@ diff --git a/dist/blocknote.js b/dist/blocknote.js
}),
...Object.values(o.schema.styleSpecs).map((n) => n.implementation.mark.configure({
editor: o
@@ -2112,7 +2115,7 @@ function mn(o, e) {
@@ -2112,7 +2115,7 @@
),
Do(o)
];
@@ -58,3 +53,40 @@ diff --git a/dist/blocknote.js b/dist/blocknote.js
}
function kn(o, e) {
const t = [
--- a/src/extensions/TableHandles/TableHandles.ts
+++ b/src/extensions/TableHandles/TableHandles.ts
@@ -572,9 +572,14 @@
const tableBody = this.tableElement!.querySelector("tbody");
if (!tableBody) {
- throw new Error(
- "Table block does not contain a 'tbody' HTML element. This should never happen.",
- );
+ this.state.show = false;
+ this.state.showAddOrRemoveRowsButton = false;
+ this.state.showAddOrRemoveColumnsButton = false;
+ this.state.rowIndex = undefined;
+ this.state.colIndex = undefined;
+ this.state.referencePosCell = undefined;
+ this.emitUpdate();
+ return;
}
if (
--- a/dist/TrailingNode-CxM966vN.js
+++ b/dist/TrailingNode-CxM966vN.js
@@ -1746,10 +1746,10 @@
);
this.state.rowIndex !== void 0 && this.state.colIndex !== void 0 && (this.state.rowIndex >= e && (this.state.rowIndex = e - 1), this.state.colIndex >= t && (this.state.colIndex = t - 1));
const o = this.tableElement.querySelector("tbody");
- if (!o)
- throw new Error(
- "Table block does not contain a 'tbody' HTML element. This should never happen."
- );
+ if (!o) {
+ this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.state.rowIndex = void 0, this.state.colIndex = void 0, this.state.referencePosCell = void 0, this.emitUpdate();
+ return;
+ }
if (this.state.rowIndex !== void 0 && this.state.colIndex !== void 0) {
const i = o.children[this.state.rowIndex].children[this.state.colIndex];
i ? this.state.referencePosCell = i.getBoundingClientRect() : (this.state.rowIndex = void 0, this.state.colIndex = void 0);

View File

@@ -2,7 +2,9 @@ import { defineConfig } from '@playwright/test'
const baseURL = process.env.BASE_URL || 'http://127.0.0.1:41741'
const port = new URL(baseURL).port || '41741'
const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER === '1'
const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER
? process.env.PLAYWRIGHT_REUSE_SERVER === '1'
: process.env.CI !== 'true'
const claudeCodeOnboardingStorageState = {
cookies: [],
origins: [

16
pnpm-lock.yaml generated
View File

@@ -6,7 +6,7 @@ settings:
patchedDependencies:
'@blocknote/core@0.46.2':
hash: 04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323
hash: dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97
@@ -27,10 +27,10 @@ importers:
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/mantine':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -4514,9 +4514,9 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/langs': 3.23.0
@@ -4524,7 +4524,7 @@ snapshots:
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
@@ -4576,7 +4576,7 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.14(react@19.2.4)
@@ -4598,7 +4598,7 @@ snapshots:
'@blocknote/react@0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=04a4b91896adec69aba5d9fedd91f76f7b54aefad70e093cbc0a18f216829323)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=dc394d11ab419b36b8d306cbbb6ce39f0722e042b63b2b7d254c7d7f90782e94)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10

View File

@@ -19,6 +19,9 @@ const command = packageManagerExec ? process.execPath : 'pnpm'
const baseCommandArgs = packageManagerExec
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage']
: ['exec', 'vitest', 'run', '--coverage']
const clearCacheCommandArgs = packageManagerExec
? [packageManagerExec, 'exec', 'vitest', '--clearCache']
: ['exec', 'vitest', '--clearCache']
function isKnownVitestInternalStateFlake(output) {
return output.includes('Vitest failed to access its internal state.')
@@ -39,6 +42,7 @@ async function runCoverageAttempt(attempt) {
await mkdir(runCoverageDir, { recursive: true })
// Vitest writes per-worker coverage shards under reportsDirectory/.tmp.
await mkdir(runCoverageTempDir, { recursive: true })
await clearVitestCache()
const commandArgs = [
...baseCommandArgs,
@@ -91,6 +95,30 @@ async function runCoverageAttempt(attempt) {
}
}
async function clearVitestCache() {
const exitCode = await new Promise((resolveExit, rejectExit) => {
const child = spawn(command, clearCacheCommandArgs, {
cwd: rootDir,
env: process.env,
stdio: 'inherit',
})
child.on('error', rejectExit)
child.on('exit', (code, signal) => {
if (signal) {
rejectExit(new Error(`Vitest cache clear exited via signal: ${signal}`))
return
}
resolveExit(code ?? 1)
})
})
if (exitCode !== 0) {
throw new Error(`Vitest cache clear failed with exit code ${exitCode}`)
}
}
let finalRun = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {

View File

@@ -1,3 +1,6 @@
#[cfg(desktop)]
use std::process::Command;
#[cfg(desktop)]
use crate::menu;
use crate::settings::Settings;
@@ -13,6 +16,93 @@ use tauri::Window;
use super::parse_build_label;
#[cfg(desktop)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TitleBarDoubleClickAction {
Fill,
Minimize,
None,
}
#[cfg(desktop)]
fn parse_title_bar_double_click_action(value: &str) -> Option<TitleBarDoubleClickAction> {
match value.trim().to_ascii_lowercase().as_str() {
"fill" | "zoom" | "maximize" => Some(TitleBarDoubleClickAction::Fill),
"minimize" => Some(TitleBarDoubleClickAction::Minimize),
"none" | "no action" | "do nothing" => Some(TitleBarDoubleClickAction::None),
_ => None,
}
}
#[cfg(desktop)]
fn parse_legacy_title_bar_double_click_action(value: &str) -> Option<TitleBarDoubleClickAction> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" => Some(TitleBarDoubleClickAction::Minimize),
"0" | "false" | "no" => Some(TitleBarDoubleClickAction::Fill),
_ => None,
}
}
#[cfg(desktop)]
fn read_global_defaults_value(key: &str) -> Option<String> {
let output = Command::new("defaults")
.args(["read", "-g", key])
.output()
.ok()?;
parse_defaults_read_output(output)
}
#[cfg(desktop)]
fn resolve_title_bar_double_click_action(
read_value: impl Fn(&str) -> Option<String>,
) -> TitleBarDoubleClickAction {
read_value("AppleActionOnDoubleClick")
.as_deref()
.and_then(parse_title_bar_double_click_action)
.or_else(|| {
read_value("AppleMiniaturizeOnDoubleClick")
.as_deref()
.and_then(parse_legacy_title_bar_double_click_action)
})
.unwrap_or(TitleBarDoubleClickAction::Fill)
}
#[cfg(desktop)]
fn parse_defaults_read_output(output: std::process::Output) -> Option<String> {
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
}
#[cfg(desktop)]
fn apply_title_bar_double_click_action(
action: TitleBarDoubleClickAction,
is_maximized: impl FnOnce() -> Result<bool, String>,
maximize: impl FnOnce() -> Result<(), String>,
unmaximize: impl FnOnce() -> Result<(), String>,
minimize: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
match action {
TitleBarDoubleClickAction::Fill => {
if is_maximized()? {
unmaximize()
} else {
maximize()
}
}
TitleBarDoubleClickAction::Minimize => minimize(),
TitleBarDoubleClickAction::None => Ok(()),
}
}
// ── MCP commands (desktop) ──────────────────────────────────────────────────
#[cfg(desktop)]
@@ -153,6 +243,20 @@ pub fn update_current_window_min_size(
.map_err(|e| e.to_string())
}
#[cfg(desktop)]
#[tauri::command]
pub fn perform_current_window_titlebar_double_click(window: Window) -> Result<(), String> {
let action = resolve_title_bar_double_click_action(read_global_defaults_value);
apply_title_bar_double_click_action(
action,
|| window.is_maximized().map_err(|e| e.to_string()),
|| window.maximize().map_err(|e| e.to_string()),
|| window.unmaximize().map_err(|e| e.to_string()),
|| window.minimize().map_err(|e| e.to_string()),
)
}
#[cfg(mobile)]
#[tauri::command]
pub fn update_current_window_min_size(
@@ -164,6 +268,12 @@ pub fn update_current_window_min_size(
Ok(())
}
#[cfg(mobile)]
#[tauri::command]
pub fn perform_current_window_titlebar_double_click(_window: tauri::Window) -> Result<(), String> {
Ok(())
}
// ── Settings & config commands ──────────────────────────────────────────────
#[tauri::command]
@@ -242,3 +352,175 @@ pub fn load_vault_list() -> Result<VaultList, String> {
pub fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(desktop)]
use std::cell::RefCell;
#[cfg(desktop)]
use std::os::unix::process::ExitStatusExt;
#[cfg(desktop)]
use std::process::{ExitStatus, Output};
#[cfg(desktop)]
use std::rc::Rc;
#[test]
fn parses_title_bar_action_values() {
for (value, expected) in [
("Fill", Some(TitleBarDoubleClickAction::Fill)),
("zoom", Some(TitleBarDoubleClickAction::Fill)),
("Minimize", Some(TitleBarDoubleClickAction::Minimize)),
("No Action", Some(TitleBarDoubleClickAction::None)),
("tile", None),
] {
assert_eq!(parse_title_bar_double_click_action(value), expected);
}
for (value, expected) in [
("1", Some(TitleBarDoubleClickAction::Minimize)),
("false", Some(TitleBarDoubleClickAction::Fill)),
("maybe", None),
] {
assert_eq!(parse_legacy_title_bar_double_click_action(value), expected);
}
}
#[test]
fn resolves_title_bar_action_preferences() {
assert_eq!(
resolve_with(&[
("AppleActionOnDoubleClick", "No Action"),
("AppleMiniaturizeOnDoubleClick", "1"),
]),
TitleBarDoubleClickAction::None
);
assert_eq!(
resolve_with(&[("AppleMiniaturizeOnDoubleClick", "1")]),
TitleBarDoubleClickAction::Minimize
);
assert_eq!(
resolve_with(&[
("AppleActionOnDoubleClick", "tile"),
("AppleMiniaturizeOnDoubleClick", "1"),
]),
TitleBarDoubleClickAction::Minimize
);
assert_eq!(resolve_with(&[]), TitleBarDoubleClickAction::Fill);
}
#[test]
fn parses_defaults_output_variants() {
for (code, stdout, expected) in [
(0, b" Maximize \n".to_vec(), Some("Maximize")),
(1, b"Minimize\n".to_vec(), None),
(0, b" \n".to_vec(), None),
(0, vec![0xff], None),
] {
assert_eq!(
parse_defaults_read_output(output(code, stdout)),
expected.map(str::to_string)
);
}
}
#[test]
fn routes_title_bar_actions_to_expected_window_calls() {
for (action, state, expected_calls) in [
(
TitleBarDoubleClickAction::Fill,
Ok(false),
vec!["is_maximized", "maximize"],
),
(
TitleBarDoubleClickAction::Fill,
Ok(true),
vec!["is_maximized", "unmaximize"],
),
(
TitleBarDoubleClickAction::Minimize,
Ok(false),
vec!["minimize"],
),
(TitleBarDoubleClickAction::None, Ok(false), Vec::new()),
] {
let (result, calls) = run_action(action, state, Ok(()), Ok(()), Ok(()));
assert_eq!(result, Ok(()));
assert_eq!(calls, expected_calls);
}
}
#[test]
fn propagates_title_bar_action_errors() {
for (state, maximize, unmaximize, expected) in [
(Err("state"), Ok(()), Ok(()), "state"),
(Ok(false), Err("maximize"), Ok(()), "maximize"),
(Ok(true), Ok(()), Err("unmaximize"), "unmaximize"),
] {
let (result, _) = run_action(
TitleBarDoubleClickAction::Fill,
state,
maximize,
unmaximize,
Ok(()),
);
assert_eq!(result, Err(expected.to_string()));
}
}
fn exit_status(code: i32) -> ExitStatus {
ExitStatus::from_raw(code << 8)
}
fn output(code: i32, stdout: Vec<u8>) -> Output {
Output {
status: exit_status(code),
stdout,
stderr: Vec::new(),
}
}
fn resolve_with(values: &[(&str, &str)]) -> TitleBarDoubleClickAction {
resolve_title_bar_double_click_action(|key| {
values
.iter()
.find(|(candidate, _)| *candidate == key)
.map(|(_, value)| (*value).to_string())
})
}
fn run_action(
action: TitleBarDoubleClickAction,
state: Result<bool, &'static str>,
maximize: Result<(), &'static str>,
unmaximize: Result<(), &'static str>,
minimize: Result<(), &'static str>,
) -> (Result<(), String>, Vec<&'static str>) {
let calls = Rc::new(RefCell::new(Vec::new()));
let state_calls = Rc::clone(&calls);
let maximize_calls = Rc::clone(&calls);
let unmaximize_calls = Rc::clone(&calls);
let minimize_calls = Rc::clone(&calls);
let result = apply_title_bar_double_click_action(
action,
move || {
state_calls.borrow_mut().push("is_maximized");
state.map_err(str::to_string)
},
move || {
maximize_calls.borrow_mut().push("maximize");
maximize.map_err(str::to_string)
},
move || {
unmaximize_calls.borrow_mut().push("unmaximize");
unmaximize.map_err(str::to_string)
},
move || {
minimize_calls.borrow_mut().push("minimize");
minimize.map_err(str::to_string)
},
);
let call_log = calls.borrow().clone();
(result, call_log)
}
}

View File

@@ -254,6 +254,7 @@ macro_rules! app_invoke_handler {
commands::update_menu_state,
commands::trigger_menu_command,
commands::update_current_window_min_size,
commands::perform_current_window_titlebar_double_click,
commands::save_settings,
commands::download_and_install_app_update,
commands::load_vault_list,

View File

@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::fs::{self, OpenOptions};
use std::hash::{Hash, Hasher};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use uuid::Uuid;
use crate::git::{get_all_file_dates, GitDates};
use std::collections::HashMap;
@@ -14,8 +17,9 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry
/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items)
/// v13: preserve plain square brackets in parsed markdown H1 titles
const CACHE_VERSION: u32 = 13;
const CACHE_WRITE_LOCK_STALE_SECS: u64 = 30;
#[derive(Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
struct VaultCache {
#[serde(default = "default_cache_version")]
version: u32,
@@ -27,6 +31,51 @@ struct VaultCache {
entries: Vec<VaultEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CacheFileFingerprint {
byte_len: usize,
content_hash: u64,
}
#[derive(Debug)]
struct LoadedCache {
cache: VaultCache,
fingerprint: CacheFileFingerprint,
}
#[derive(Debug)]
enum CacheLoadState {
Missing,
Loaded(LoadedCache),
Invalid(String),
Unreadable(String),
}
#[derive(Debug, Eq, PartialEq)]
enum CacheWriteOutcome {
Replaced,
SkippedConcurrentUpdate,
SkippedActiveWriter,
}
struct CacheWriteLock {
path: PathBuf,
}
impl Drop for CacheWriteLock {
fn drop(&mut self) {
if let Err(error) = fs::remove_file(&self.path) {
if error.kind() != ErrorKind::NotFound {
log::warn!(
"Failed to release cache write lock {}: {}",
self.path.display(),
error
);
}
}
}
}
fn default_cache_version() -> u32 {
1
}
@@ -53,6 +102,18 @@ fn cache_path(vault: &Path) -> PathBuf {
cache_dir().join(format!("{}.json", vault_path_hash(vault)))
}
fn cache_lock_path(vault: &Path) -> PathBuf {
cache_path(vault).with_extension("lock")
}
fn cache_temp_path(final_path: &Path) -> PathBuf {
let file_name = final_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("cache.json");
final_path.with_file_name(format!("{file_name}.{}.tmp", Uuid::new_v4()))
}
/// Legacy cache path inside the vault directory (pre-migration).
fn legacy_cache_path(vault: &Path) -> PathBuf {
vault.join(".laputa-cache.json")
@@ -156,25 +217,234 @@ fn git_uncommitted_files(vault: &Path) -> Vec<String> {
files
}
fn load_cache(vault: &Path) -> Option<VaultCache> {
let data = fs::read_to_string(cache_path(vault)).ok()?;
serde_json::from_str(&data).ok()
fn cache_fingerprint(bytes: &[u8]) -> CacheFileFingerprint {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
CacheFileFingerprint {
byte_len: bytes.len(),
content_hash: hasher.finish(),
}
}
/// Write cache atomically: write to a temp file then rename.
fn write_cache(vault: &Path, cache: &VaultCache) {
let final_path = cache_path(vault);
if let Some(parent) = final_path.parent() {
let _ = fs::create_dir_all(parent);
fn read_cache_bytes(path: &Path) -> Result<Option<Vec<u8>>, String> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!(
"Failed to read cache {}: {}",
path.display(),
error
)),
}
let tmp_path = final_path.with_extension("tmp");
if let Ok(data) = serde_json::to_string(cache) {
if fs::write(&tmp_path, &data).is_ok() {
let _ = fs::rename(&tmp_path, &final_path);
}
fn read_cache_fingerprint(path: &Path) -> Result<Option<CacheFileFingerprint>, String> {
Ok(read_cache_bytes(path)?.map(|bytes| cache_fingerprint(&bytes)))
}
fn load_cache(vault: &Path) -> CacheLoadState {
let path = cache_path(vault);
let Some(bytes) = (match read_cache_bytes(&path) {
Ok(bytes) => bytes,
Err(error) => return CacheLoadState::Unreadable(error),
}) else {
return CacheLoadState::Missing;
};
let fingerprint = cache_fingerprint(&bytes);
match serde_json::from_slice(&bytes) {
Ok(cache) => CacheLoadState::Loaded(LoadedCache { cache, fingerprint }),
Err(error) => CacheLoadState::Invalid(format!(
"Failed to parse cache {}: {}",
path.display(),
error
)),
}
}
fn lock_is_stale(lock_path: &Path) -> bool {
fs::metadata(lock_path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(|modified| modified.elapsed().ok())
.map(|elapsed| elapsed > Duration::from_secs(CACHE_WRITE_LOCK_STALE_SECS))
.unwrap_or(false)
}
fn ensure_cache_parent_dir(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create cache directory {}: {}",
parent.display(),
error
)
})?;
}
Ok(())
}
fn initialize_cache_write_lock(
mut file: fs::File,
lock_path: &Path,
) -> Result<CacheWriteLock, String> {
let pid = std::process::id().to_string();
if let Err(error) = file.write_all(pid.as_bytes()).and_then(|_| file.sync_all()) {
let _ = fs::remove_file(lock_path);
return Err(format!(
"Failed to initialize cache write lock {}: {}",
lock_path.display(),
error
));
}
Ok(CacheWriteLock {
path: lock_path.to_path_buf(),
})
}
fn try_create_cache_write_lock(lock_path: &Path) -> Result<Option<CacheWriteLock>, String> {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(lock_path)
{
Ok(file) => initialize_cache_write_lock(file, lock_path).map(Some),
Err(error) if error.kind() == ErrorKind::AlreadyExists => Ok(None),
Err(error) => Err(format!(
"Failed to acquire cache write lock {}: {}",
lock_path.display(),
error
)),
}
}
fn remove_stale_cache_write_lock(lock_path: &Path) -> Result<bool, String> {
if !lock_is_stale(lock_path) {
return Ok(false);
}
log::warn!("Removing stale cache write lock {}", lock_path.display());
match fs::remove_file(lock_path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(true),
Err(error) => Err(format!(
"Failed to remove stale cache write lock {}: {}",
lock_path.display(),
error
)),
}
}
fn acquire_cache_write_lock(lock_path: &Path) -> Result<Option<CacheWriteLock>, String> {
ensure_cache_parent_dir(lock_path)?;
if let Some(lock) = try_create_cache_write_lock(lock_path)? {
return Ok(Some(lock));
}
if !remove_stale_cache_write_lock(lock_path)? {
return Ok(None);
}
try_create_cache_write_lock(lock_path)
}
fn remove_cache_file(path: &Path, reason: &str) {
if let Err(error) = fs::remove_file(path) {
if error.kind() != ErrorKind::NotFound {
log::warn!("Failed to remove {reason} {}: {}", path.display(), error);
}
}
}
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> Result<(), String> {
let Some(parent) = path.parent() else {
return Ok(());
};
fs::File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(|error| {
format!(
"Failed to sync cache directory {}: {}",
parent.display(),
error
)
})
}
#[cfg(not(unix))]
fn sync_parent_directory(_path: &Path) -> Result<(), String> {
Ok(())
}
/// Replace the cache file using a temp file + rename, but only if the on-disk
/// cache still matches the version we loaded earlier.
fn write_cache(
vault: &Path,
cache: &VaultCache,
expected_previous: Option<CacheFileFingerprint>,
) -> Result<CacheWriteOutcome, String> {
let final_path = cache_path(vault);
let lock_path = cache_lock_path(vault);
let Some(_lock) = acquire_cache_write_lock(&lock_path)? else {
return Ok(CacheWriteOutcome::SkippedActiveWriter);
};
let current_fingerprint = read_cache_fingerprint(&final_path)?;
let still_matches_loaded_state = match expected_previous.as_ref() {
Some(expected) => current_fingerprint.as_ref() == Some(expected),
None => current_fingerprint.is_none(),
};
if !still_matches_loaded_state {
return Ok(CacheWriteOutcome::SkippedConcurrentUpdate);
}
ensure_cache_parent_dir(&final_path)?;
let data = serde_json::to_vec(cache).map_err(|error| {
format!(
"Failed to serialize cache {}: {}",
final_path.display(),
error
)
})?;
let tmp_path = cache_temp_path(&final_path);
let mut tmp_file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
.map_err(|error| {
format!(
"Failed to create temp cache file {}: {}",
tmp_path.display(),
error
)
})?;
if let Err(error) = tmp_file.write_all(&data).and_then(|_| tmp_file.sync_all()) {
remove_cache_file(&tmp_path, "temp cache file");
return Err(format!(
"Failed to flush temp cache file {}: {}",
tmp_path.display(),
error
));
}
drop(tmp_file);
if let Err(error) = fs::rename(&tmp_path, &final_path) {
remove_cache_file(&tmp_path, "temp cache file");
return Err(format!(
"Failed to replace cache {}: {}",
final_path.display(),
error
));
}
if let Err(error) = sync_parent_directory(&final_path) {
log::warn!("{error}");
}
Ok(CacheWriteOutcome::Replaced)
}
/// Normalize an absolute path to a relative path for comparison with git output.
fn to_relative_path(abs_path: &str, vault: &Path) -> String {
let vault_str = vault.to_string_lossy();
@@ -213,7 +483,7 @@ fn parse_files_at(
.collect()
}
/// Copy legacy cache data to the new external location atomically.
/// Copy legacy cache data to the new external location via temp file + rename.
fn copy_legacy_cache_to(legacy: &Path, dest: &Path) {
if let Some(parent) = dest.parent() {
let _ = fs::create_dir_all(parent);
@@ -272,10 +542,15 @@ fn prune_stale_entries(vault: &Path, entries: &mut Vec<VaultEntry>) -> bool {
}
/// Sort entries by modified_at descending and write the cache.
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
fn finalize_and_cache(
vault: &Path,
mut entries: Vec<VaultEntry>,
hash: String,
expected_previous: Option<CacheFileFingerprint>,
) -> Vec<VaultEntry> {
prune_stale_entries(vault, &mut entries);
entries.sort_by_key(|entry| std::cmp::Reverse(entry.modified_at));
write_cache(
let outcome = write_cache(
vault,
&VaultCache {
version: CACHE_VERSION,
@@ -283,7 +558,20 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
commit_hash: hash,
entries: entries.clone(),
},
expected_previous,
);
match outcome {
Ok(CacheWriteOutcome::Replaced) => {}
Ok(CacheWriteOutcome::SkippedConcurrentUpdate) => log::info!(
"Skipped replacing cache {} because another scan refreshed it first",
cache_path(vault).display()
),
Ok(CacheWriteOutcome::SkippedActiveWriter) => log::info!(
"Skipped replacing cache {} because another writer is active",
cache_path(vault).display()
),
Err(error) => log::warn!("{error}"),
}
entries
}
@@ -292,9 +580,10 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
/// deleted outside git (e.g., via Finder) are removed from the cache on vault open.
fn update_same_commit(
vault: &Path,
cache: VaultCache,
loaded_cache: LoadedCache,
git_dates: &HashMap<String, GitDates>,
) -> Vec<VaultEntry> {
let LoadedCache { cache, fingerprint } = loaded_cache;
let changed = git_uncommitted_files(vault);
let mut entries = cache.entries;
if !changed.is_empty() {
@@ -304,16 +593,17 @@ fn update_same_commit(
}
// Always finalize: prune_stale_entries inside finalize_and_cache removes
// entries for files deleted outside git (e.g., via Finder or another app).
finalize_and_cache(vault, entries, cache.commit_hash)
finalize_and_cache(vault, entries, cache.commit_hash, Some(fingerprint))
}
/// Handle different-commit cache: incremental update via git diff.
fn update_different_commit(
vault: &Path,
cache: VaultCache,
loaded_cache: LoadedCache,
current_hash: String,
git_dates: &HashMap<String, GitDates>,
) -> Vec<VaultEntry> {
let LoadedCache { cache, fingerprint } = loaded_cache;
let changed_files = git_changed_files(vault, &cache.commit_hash, &current_hash);
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
@@ -324,7 +614,7 @@ fn update_different_commit(
.collect();
entries.extend(parse_files_at(vault, &changed_files, git_dates));
finalize_and_cache(vault, entries, current_hash)
finalize_and_cache(vault, entries, current_hash, Some(fingerprint))
}
fn cache_requires_full_rescan(cache: &VaultCache, vault_path: &Path) -> bool {
@@ -337,9 +627,15 @@ fn scan_and_cache_full(
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
current_hash: String,
expected_previous: Option<CacheFileFingerprint>,
) -> Result<Vec<VaultEntry>, String> {
let entries = scan_vault(vault_path, git_dates)?;
Ok(finalize_and_cache(vault_path, entries, current_hash))
Ok(finalize_and_cache(
vault_path,
entries,
current_hash,
expected_previous,
))
}
/// Delete the cache file for a vault, forcing a full rescan on the next
@@ -347,7 +643,7 @@ fn scan_and_cache_full(
/// explicit user-triggered reloads always read from the filesystem.
pub fn invalidate_cache(vault_path: &Path) {
let path = cache_path(vault_path);
let _ = fs::remove_file(&path);
remove_cache_file(&path, "cache file");
}
/// Scan vault with incremental caching via git.
@@ -371,24 +667,37 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
// Build git dates map once — used by all code paths below
let git_dates = get_all_file_dates(vault_path);
if let Some(cache) = load_cache(vault_path) {
if cache_requires_full_rescan(&cache, vault_path) {
return scan_and_cache_full(vault_path, &git_dates, current_hash);
match load_cache(vault_path) {
CacheLoadState::Missing => {}
CacheLoadState::Unreadable(error) => log::warn!("{error}"),
CacheLoadState::Invalid(error) => {
log::warn!("{error}");
remove_cache_file(&cache_path(vault_path), "invalid cache file");
}
CacheLoadState::Loaded(loaded_cache) => {
if cache_requires_full_rescan(&loaded_cache.cache, vault_path) {
return scan_and_cache_full(
vault_path,
&git_dates,
current_hash,
Some(loaded_cache.fingerprint),
);
}
return if loaded_cache.cache.commit_hash == current_hash {
Ok(update_same_commit(vault_path, loaded_cache, &git_dates))
} else {
Ok(update_different_commit(
vault_path,
loaded_cache,
current_hash,
&git_dates,
))
};
}
return if cache.commit_hash == current_hash {
Ok(update_same_commit(vault_path, cache, &git_dates))
} else {
Ok(update_different_commit(
vault_path,
cache,
current_hash,
&git_dates,
))
};
}
// No cache — full scan and write cache
scan_and_cache_full(vault_path, &git_dates, current_hash)
scan_and_cache_full(vault_path, &git_dates, current_hash, None)
}
#[cfg(test)]
@@ -499,7 +808,7 @@ mod tests {
}
#[test]
fn test_atomic_write_no_tmp_file_left() {
fn test_cache_write_no_tmp_file_left() {
let _lock = ENV_LOCK.lock().unwrap();
let cache_dir = TempDir::new().unwrap();
set_test_cache_dir(cache_dir.path());
@@ -514,18 +823,19 @@ mod tests {
entries: vec![],
};
write_cache(vault, &cache);
write_cache(vault, &cache, None).unwrap();
// Final file should exist
let final_path = cache_path(vault);
assert!(final_path.exists(), "cache file must exist after write");
// Tmp file should NOT exist (renamed away)
let tmp_path = final_path.with_extension("tmp");
assert!(
!tmp_path.exists(),
"tmp file must not exist after atomic write"
);
// Tmp files should NOT remain beside the cache file
let tmp_count = fs::read_dir(cache_dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().contains(".tmp"))
.count();
assert_eq!(tmp_count, 0, "cache write must not leave tmp files behind");
// Content must be valid JSON
let data = fs::read_to_string(&final_path).unwrap();
@@ -972,7 +1282,7 @@ mod tests {
commit_hash: hash,
entries: vec![stale_entry],
};
write_cache(vault, &stale_cache);
write_cache(vault, &stale_cache, None).unwrap();
// Load via cached path — stale version must trigger full rescan
let entries = scan_vault_cached(vault).unwrap();
@@ -1034,4 +1344,79 @@ mod tests {
"committed .yml file must be picked up by incremental cache update"
);
}
#[test]
fn test_load_cache_marks_invalid_json() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
fs::write(cache_path(vault), "{ not-json").unwrap();
let load = load_cache(vault);
assert!(
matches!(load, CacheLoadState::Invalid(_)),
"invalid cache JSON must be distinguished from a cache miss"
);
}
#[test]
fn test_write_cache_skips_overwriting_newer_cache() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
let original = VaultCache {
version: CACHE_VERSION,
vault_path: vault.to_string_lossy().to_string(),
commit_hash: "original".to_string(),
entries: vec![],
};
write_cache(vault, &original, None).unwrap();
let CacheLoadState::Loaded(loaded) = load_cache(vault) else {
panic!("expected original cache to load");
};
let newer = VaultCache {
commit_hash: "newer".to_string(),
..original
};
write_cache(vault, &newer, Some(loaded.fingerprint.clone())).unwrap();
let stale = VaultCache {
commit_hash: "stale".to_string(),
..newer
};
let outcome = write_cache(vault, &stale, Some(loaded.fingerprint)).unwrap();
assert_eq!(outcome, CacheWriteOutcome::SkippedConcurrentUpdate);
let CacheLoadState::Loaded(final_cache) = load_cache(vault) else {
panic!("expected final cache to load");
};
assert_eq!(final_cache.cache.commit_hash, "newer");
}
#[test]
fn test_write_cache_skips_when_writer_lock_is_held() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
let lock_path = cache_lock_path(vault);
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&lock_path, "busy").unwrap();
let cache = VaultCache {
version: CACHE_VERSION,
vault_path: vault.to_string_lossy().to_string(),
commit_hash: "busy".to_string(),
entries: vec![],
};
let outcome = write_cache(vault, &cache, None).unwrap();
assert_eq!(outcome, CacheWriteOutcome::SkippedActiveWriter);
assert!(
!cache_path(vault).exists(),
"active writer lock must prevent a competing cache write"
);
}
}

View File

@@ -21,6 +21,10 @@ pub(crate) fn read_file_metadata(path: &Path) -> Result<(Option<u64>, Option<u64
Ok((modified_at, created_at, metadata.len()))
}
fn invalid_utf8_text_error(path: &Path) -> String {
format!("File is not valid UTF-8 text: {}", path.display())
}
/// Read the content of a single note file.
pub fn get_note_content(path: &Path) -> Result<String, String> {
if !path.exists() {
@@ -29,7 +33,8 @@ pub fn get_note_content(path: &Path) -> Result<String, String> {
if !path.is_file() {
return Err(format!("Path is not a file: {}", path.display()));
}
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))
let bytes = fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
String::from_utf8(bytes).map_err(|_| invalid_utf8_text_error(path))
}
fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> {

View File

@@ -213,8 +213,8 @@ pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
/// Directories hidden from user-facing vault scans.
const HIDDEN_DIRS: &[&str] = &[".git", ".laputa", ".DS_Store"];
/// System folders that hold Tolaria metadata or assets rather than user note groups.
const FOLDER_TREE_EXCLUDED_DIRS: &[&str] = &["attachments", "type", "views"];
/// Keep type definitions in their dedicated sidebar section instead of the generic folder tree.
const FOLDER_TREE_EXCLUDED_DIRS: &[&str] = &["type"];
fn is_hidden_dir(name: &str) -> bool {
name.starts_with('.') || HIDDEN_DIRS.contains(&name)

View File

@@ -34,7 +34,7 @@ fn test_scan_vault_folders_excludes_hidden() {
}
#[test]
fn test_scan_vault_folders_excludes_system_asset_folders() {
fn test_scan_vault_folders_keeps_default_vault_folders_visible() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("attachments")).unwrap();
std::fs::create_dir_all(dir.path().join("type")).unwrap();
@@ -44,7 +44,7 @@ fn test_scan_vault_folders_excludes_system_asset_folders() {
let folders = scan_vault_folders(dir.path()).unwrap();
let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect();
assert_eq!(names, vec!["projects"]);
assert_eq!(names, vec!["attachments", "projects", "views"]);
}
#[test]

View File

@@ -120,3 +120,17 @@ fn test_get_note_content_nonexistent() {
let result = get_note_content(Path::new("/nonexistent/path/file.md"));
assert!(result.is_err());
}
#[test]
fn test_get_note_content_invalid_utf8() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("invalid.csv");
std::fs::write(&path, [0x66, 0x6f, 0x80]).unwrap();
let result = get_note_content(&path);
assert_eq!(
result.unwrap_err(),
format!("File is not valid UTF-8 text: {}", path.display())
);
}

View File

@@ -1,6 +1,7 @@
import { act, render, screen, fireEvent, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
const localStorageMock = (() => {
@@ -95,6 +96,9 @@ const mockVaultList = {
hidden_defaults: [],
}
const mockDefaultVaultPath = '/Users/mock/Documents/Getting Started'
const expectedDefaultVaultPath = DEFAULT_VAULTS[0].path || mockDefaultVaultPath
const mockCommandResults: Record<string, unknown> = {
load_vault_list: mockVaultList,
list_vault: mockEntries,
@@ -108,7 +112,7 @@ const mockCommandResults: Record<string, unknown> = {
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
save_settings: null,
check_vault_exists: true,
get_default_vault_path: '/Users/mock/Documents/Getting Started',
get_default_vault_path: expectedDefaultVaultPath,
list_themes: [],
get_vault_settings: { theme: null },
}
@@ -245,7 +249,7 @@ function resetMockCommandResults() {
},
save_settings: null,
check_vault_exists: true,
get_default_vault_path: '/Users/mock/Documents/Getting Started',
get_default_vault_path: expectedDefaultVaultPath,
list_themes: [],
get_vault_settings: { theme: null },
})
@@ -276,6 +280,7 @@ vi.mock('./utils/ai-chat', () => ({
vi.mock('@blocknote/core', () => ({
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
createCodeBlockSpec: vi.fn(() => ({})),
createExtension: (factory: unknown) => () => factory,
defaultInlineContentSpecs: {},
filterSuggestionItems: vi.fn(() => []),
}))
@@ -420,7 +425,7 @@ describe('App', () => {
release_channel: null,
}
mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] }
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === expectedDefaultVaultPath
render(<App />)
@@ -440,7 +445,7 @@ describe('App', () => {
['telemetry-accept', 'Allow anonymous reporting'],
['telemetry-decline', 'No thanks'],
])('ignores a remembered default vault after %s when onboarding was never completed', async (buttonTestId) => {
const rememberedDefaultVaultPath = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
const rememberedDefaultVaultPath = expectedDefaultVaultPath
localStorage.setItem('tolaria_welcome_dismissed', '1')
mockCommandResults.get_default_vault_path = rememberedDefaultVaultPath
mockCommandResults.get_settings = {
@@ -516,7 +521,7 @@ describe('App', () => {
active_vault: '/missing-vault',
hidden_defaults: [],
}
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === expectedDefaultVaultPath
render(<App />)
@@ -533,7 +538,7 @@ describe('App', () => {
active_vault: null,
hidden_defaults: [],
}
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === '/Users/mock/Documents/Getting Started'
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === expectedDefaultVaultPath
render(<App />)
@@ -544,6 +549,97 @@ describe('App', () => {
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it('persists and opens an existing vault chosen from onboarding', async () => {
const selectedVaultPath = '/Users/mock/Documents/Work Vault'
const selectedVaultUrl = 'file:///Users/mock/Documents/Work%20Vault'
const saveVaultList = vi.fn()
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue(selectedVaultUrl)
mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] }
mockCommandResults.check_vault_exists = (args?: { path?: string }) => args?.path === selectedVaultPath
mockCommandResults.save_vault_list = (args?: {
list?: { vaults?: Array<{ label: string; path: string }>; active_vault?: string | null }
}) => {
saveVaultList(args)
return null
}
render(<App />)
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('welcome-open-folder'))
await waitFor(() => {
expect(saveVaultList).toHaveBeenCalledWith({
list: {
vaults: [{ label: 'Work Vault', path: selectedVaultPath }],
active_vault: selectedVaultPath,
hidden_defaults: [],
},
})
})
expect(saveVaultList).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent('Work Vault')
})
promptSpy.mockRestore()
})
it('persists and opens the onboarding template vault after cloning', async () => {
let templateExists = false
const saveVaultList = vi.fn()
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('file:///Users/mock/Documents')
const expectedLabel = 'Getting Started'
mockCommandResults.load_vault_list = { vaults: [], active_vault: null, hidden_defaults: [] }
mockCommandResults.check_vault_exists = (args?: { path?: string }) => {
if (args?.path === expectedDefaultVaultPath) {
return templateExists
}
return false
}
mockCommandResults.create_getting_started_vault = () => {
templateExists = true
return expectedDefaultVaultPath
}
mockCommandResults.save_vault_list = (args?: {
list?: { vaults?: Array<{ label: string; path: string }>; active_vault?: string | null }
}) => {
saveVaultList(args)
return null
}
render(<App />)
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('welcome-create-vault'))
await waitFor(() => {
expect(saveVaultList).toHaveBeenCalledWith({
list: {
vaults: [],
active_vault: expectedDefaultVaultPath,
hidden_defaults: [],
},
})
})
expect(saveVaultList).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(screen.getByTestId('status-vault-trigger')).toHaveTextContent(expectedLabel)
})
promptSpy.mockRestore()
})
it('renders sidebar with correct default selection (All Notes)', async () => {
render(<App />)
await waitFor(() => {

View File

@@ -248,9 +248,15 @@ function App() {
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
onToast: (msg) => setToastMessage(msg),
})
const { allVaults, defaultPath, handleVaultCloned, selectedVaultPath, switchVault } = vaultSwitcher
const {
allVaults,
registerVaultSelection,
selectedVaultPath,
syncVaultSelection,
switchVault,
} = vaultSwitcher
const rememberOnboardingVaultChoice = useCallback((vaultPath: string) => {
const rememberVaultChoice = useCallback((vaultPath: string) => {
if (!vaultPath) return
if (allVaults.some((vault) => vault.path === vaultPath)) {
@@ -259,33 +265,30 @@ function App() {
}
const label = vaultPath.split('/').filter(Boolean).pop() || 'Local Vault'
handleVaultCloned(vaultPath, label)
}, [allVaults, handleVaultCloned, switchVault])
syncVaultSelection(vaultPath, label)
}, [allVaults, switchVault, syncVaultSelection])
const handleGettingStartedVaultReady = useCallback((vaultPath: string) => {
rememberOnboardingVaultChoice(vaultPath)
rememberVaultChoice(vaultPath)
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
}, [rememberOnboardingVaultChoice])
}, [rememberVaultChoice])
const handleOnboardingVaultReady = useCallback((vaultPath: string, source: 'template' | 'empty' | 'existing') => {
rememberVaultChoice(vaultPath)
if (source === 'template') {
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
}
}, [rememberVaultChoice])
const cloneGettingStartedVault = useGettingStartedClone({
onError: (message) => setToastMessage(message),
onSuccess: handleGettingStartedVaultReady,
})
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
handleGettingStartedVaultReady(vaultPath)
const onboarding = useOnboarding(vaultSwitcher.vaultPath, {
onVaultReady: handleOnboardingVaultReady,
registerVault: registerVaultSelection,
}, vaultSwitcher.loaded)
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
const lastHandledOnboardingUserVaultPathRef = useRef<string | null>(null)
useEffect(() => {
const onboardingVaultPath = onboarding.userReadyVaultPath
if (!onboardingVaultPath || lastHandledOnboardingUserVaultPathRef.current === onboardingVaultPath) return
lastHandledOnboardingUserVaultPathRef.current = onboardingVaultPath
if (onboardingVaultPath !== vaultSwitcher.vaultPath) {
rememberOnboardingVaultChoice(onboardingVaultPath)
}
}, [onboarding.userReadyVaultPath, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
// Onboarding can briefly own the vault path for a newly created/opened vault
// before the persisted switcher catches up, but once the path is already in
@@ -1306,13 +1309,13 @@ function App() {
const shouldResumeFreshStartOnboarding = useMemo(() => {
if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
const remembersOnlyDefaultVault = selectedVaultPath === null || selectedVaultPath === defaultPath
const remembersOnlyImplicitDefaultVault = selectedVaultPath === null
return remembersOnlyDefaultVault
return remembersOnlyImplicitDefaultVault
&& vaultSwitcher.allVaults.length === 1
&& vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
}, [defaultPath, onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
}, [onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
// Show loading spinner while checking vault (skip for note windows)
if (!noteWindowParams && onboarding.state.status === 'loading') {

View File

@@ -3,6 +3,12 @@ import { describe, it, expect, vi } from 'vitest'
import { BreadcrumbBar } from './BreadcrumbBar'
import type { VaultEntry } from '../types'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
const baseEntry: VaultEntry = {
path: '/vault/note/test.md',
filename: 'test.md',
@@ -63,6 +69,15 @@ async function expectTooltip(trigger: HTMLElement, ...parts: string[]) {
}
describe('BreadcrumbBar — drag region', () => {
it('forwards mousedown events to the shared drag-region hook', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.querySelector('.breadcrumb-bar') as HTMLElement
fireEvent.mouseDown(bar, { button: 0 })
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
})
it('has data-tauri-drag-region on the container', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.firstElementChild as HTMLElement

View File

@@ -19,6 +19,7 @@ import {
} from '@phosphor-icons/react'
import { NoteTitleIcon } from './NoteTitleIcon'
import { slugify } from '../hooks/useNoteCreation'
import { useDragRegion } from '../hooks/useDragRegion'
interface BreadcrumbBarProps {
entry: VaultEntry
@@ -501,12 +502,15 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
onRenameFilename,
...actionProps
}: BreadcrumbBarProps) {
const { onMouseDown } = useDragRegion()
return (
<TooltipProvider>
<div
ref={barRef}
data-tauri-drag-region
data-title-hidden=""
onMouseDown={onMouseDown}
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,

View File

@@ -24,6 +24,7 @@ const mockEditor = vi.hoisted(() => ({
insertBlocks: vi.fn(),
document: [{ id: '1', type: 'paragraph', content: [], props: {}, children: [] }],
insertInlineContent: vi.fn(),
headless: false,
onMount: vi.fn((cb: () => void) => { cb(); return () => {} }),
prosemirrorView: {} as Record<string, unknown>,
blocksToHTMLLossy: vi.fn(() => ''),
@@ -37,6 +38,7 @@ const mockEditor = vi.hoisted(() => ({
vi.mock('@blocknote/core', () => ({
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
createCodeBlockSpec: vi.fn(() => ({})),
createExtension: (factory: unknown) => () => factory,
defaultInlineContentSpecs: {},
filterSuggestionItems: vi.fn(() => []),
}))

View File

@@ -20,6 +20,7 @@ import {
resolveRawModeContent,
} from './editorRawModeSync'
import { useRawModeWithFlush } from './useRawModeWithFlush'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
import './Editor.css'
import './EditorTheme.css'
@@ -169,6 +170,7 @@ function useEditorSetup({
const editor = useCreateBlockNote({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
extensions: [createArrowLigaturesExtension()],
})
useFilenameAutolinkGuard(editor)
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null

View File

@@ -1,4 +1,4 @@
import { act, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { VaultEntry } from '../types'
@@ -7,6 +7,7 @@ const state = vi.hoisted(() => ({
capturedToolbarProps: null as null | Record<string, unknown>,
capturedSuggestionProps: {} as Record<string, Record<string, unknown>>,
capturedImageDropArgs: null as null | Record<string, unknown>,
capturedBlockNoteOnChange: null as null | (() => void),
hoverGuardMock: vi.fn(),
imageDropState: { isDragOver: false },
linkActivationMock: vi.fn(),
@@ -24,6 +25,7 @@ vi.mock('@blocknote/react', () => ({
formattingToolbar?: boolean
slashMenu?: boolean
sideMenu?: boolean
onChange?: () => void
}) => {
const {
children,
@@ -34,6 +36,7 @@ vi.mock('@blocknote/react', () => ({
sideMenu,
...restProps
} = props
state.capturedBlockNoteOnChange = props.onChange ?? null
void formattingToolbar
void slashMenu
void sideMenu
@@ -199,6 +202,7 @@ describe('SingleEditorView', () => {
state.capturedToolbarProps = null
state.capturedSuggestionProps = {}
state.capturedImageDropArgs = null
state.capturedBlockNoteOnChange = null
state.imageDropState.isDragOver = false
state.wikilinkEntriesRef.current = []
delete window.__laputaTest
@@ -301,4 +305,33 @@ describe('SingleEditorView', () => {
expect(onWikiItemClick).toHaveBeenCalledOnce()
expect(onMentionItemClick).toHaveBeenCalledOnce()
})
it('defers rich-editor change propagation until IME composition ends', async () => {
const editor = createEditor()
const onChange = vi.fn()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
onChange={onChange}
/>,
)
const blockNoteView = screen.getByTestId('blocknote-view')
fireEvent.compositionStart(blockNoteView)
act(() => {
state.capturedBlockNoteOnChange?.()
})
expect(onChange).not.toHaveBeenCalled()
fireEvent.compositionEnd(blockNoteView)
await act(async () => {
await Promise.resolve()
})
expect(onChange).toHaveBeenCalledTimes(1)
})
})

View File

@@ -182,6 +182,57 @@ function useEditorContainerClickHandler(options: {
}, [editor, editable])
}
function useCompositionAwareEditorChange(options: {
containerRef: React.RefObject<HTMLDivElement | null>
onChange?: () => void
}) {
const { containerRef, onChange } = options
const onChangeRef = useRef(onChange)
const composingRef = useRef(false)
const pendingChangeRef = useRef(false)
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
useEffect(() => {
const container = containerRef.current
if (!container) return
const flushPendingChange = () => {
if (composingRef.current || !pendingChangeRef.current) return
pendingChangeRef.current = false
onChangeRef.current?.()
}
const handleCompositionStart = () => {
composingRef.current = true
}
const handleCompositionEnd = () => {
composingRef.current = false
queueMicrotask(flushPendingChange)
}
container.addEventListener('compositionstart', handleCompositionStart, true)
container.addEventListener('compositionend', handleCompositionEnd, true)
return () => {
container.removeEventListener('compositionstart', handleCompositionStart, true)
container.removeEventListener('compositionend', handleCompositionEnd, true)
}
}, [containerRef])
return useCallback(() => {
if (composingRef.current) {
pendingChangeRef.current = true
return
}
pendingChangeRef.current = false
onChangeRef.current?.()
}, [])
}
function buildBaseSuggestionItems(entries: VaultEntry[]) {
return deduplicateByPath(entries.map(entry => ({
title: entry.title,
@@ -273,6 +324,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const { cssVars } = useEditorTheme()
const containerRef = useRef<HTMLDivElement>(null)
const handleContainerClick = useEditorContainerClickHandler({ editable, editor })
const handleEditorChange = useCompositionAwareEditorChange({ containerRef, onChange })
const onImageUrl = useInsertImageCallback(editor)
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
useBlockNoteSideMenuHoverGuard(containerRef)
@@ -309,7 +361,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
<SharedContextBlockNoteView
editor={editor}
theme="light"
onChange={onChange}
onChange={handleEditorChange}
editable={editable}
formattingToolbar={false}
slashMenu={false}

View File

@@ -0,0 +1,132 @@
import { describe, expect, it, vi } from 'vitest'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
function createFixture() {
let beforeInputListener: ((event: InputEvent) => void) | null = null
const transaction = { insertText: vi.fn(() => transaction) }
const view = {
dispatch: vi.fn(),
state: {
doc: {
textBetween: vi.fn(),
},
selection: {
from: 2,
to: 2,
},
tr: transaction,
},
}
const dom = {
addEventListener: vi.fn((type: string, listener: (event: InputEvent) => void) => {
if (type === 'beforeinput') {
beforeInputListener = listener
}
}),
}
const editor = {
_tiptapEditor: { view },
prosemirrorView: view,
}
const extension = createArrowLigaturesExtension()({ editor: editor as never })
return {
dom,
editor,
extension,
fireInput(event: Partial<InputEvent> = {}) {
if (!beforeInputListener) {
throw new Error('Arrow ligatures extension did not register a beforeinput listener')
}
const inputEvent = {
data: '>',
inputType: 'insertText',
preventDefault: vi.fn(),
...event,
}
beforeInputListener(inputEvent as InputEvent)
return inputEvent
},
mount() {
const controller = new AbortController()
extension.mount?.({
dom: dom as never,
root: document,
signal: controller.signal,
})
return controller
},
transaction,
view,
}
}
describe('createArrowLigaturesExtension', () => {
it('registers a beforeinput listener when the editor mounts', () => {
const fixture = createFixture()
fixture.mount()
expect(fixture.dom.addEventListener).toHaveBeenCalledTimes(1)
expect(fixture.dom.addEventListener).toHaveBeenCalledWith(
'beforeinput',
expect.any(Function),
expect.objectContaining({
capture: true,
signal: expect.any(AbortSignal),
}),
)
})
it('replaces completed ASCII arrows through the mounted input listener', () => {
const fixture = createFixture()
fixture.mount()
fixture.view.state.doc.textBetween.mockReturnValue('-')
const event = fixture.fireInput()
expect(event.preventDefault).toHaveBeenCalledTimes(1)
expect(fixture.transaction.insertText).toHaveBeenCalledWith('→', 1, 2)
expect(fixture.view.dispatch).toHaveBeenCalledWith(fixture.transaction)
})
it('preserves escaped <-> as literal ASCII on the next keystroke', () => {
const fixture = createFixture()
fixture.mount()
fixture.view.state.selection = { from: 3, to: 3 }
fixture.view.state.doc.textBetween.mockReturnValueOnce('\\<')
fixture.fireInput({ data: '-' })
fixture.view.state.doc.textBetween.mockReturnValueOnce('<-')
const event = fixture.fireInput({ data: '>' })
expect(event.preventDefault).not.toHaveBeenCalled()
expect(fixture.transaction.insertText).toHaveBeenCalledTimes(1)
expect(fixture.transaction.insertText).toHaveBeenCalledWith('<-', 1, 3)
})
it('ignores non-text input so paste stays literal', () => {
const fixture = createFixture()
fixture.mount()
const event = fixture.fireInput({ inputType: 'insertFromPaste' })
expect(event.preventDefault).not.toHaveBeenCalled()
expect(fixture.transaction.insertText).not.toHaveBeenCalled()
expect(fixture.view.dispatch).not.toHaveBeenCalled()
})
it('ignores composing input so IME text is not rewritten', () => {
const fixture = createFixture()
fixture.mount()
const event = fixture.fireInput({ isComposing: true })
expect(event.preventDefault).not.toHaveBeenCalled()
expect(fixture.transaction.insertText).not.toHaveBeenCalled()
expect(fixture.view.dispatch).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,68 @@
import { createExtension } from '@blocknote/core'
import { resolveArrowLigatureInput } from '../utils/arrowLigatures'
const PREFIX_CONTEXT_LENGTH = 2
function isInsertedCharacter(event: InputEvent): event is InputEvent & { data: string } {
return event.inputType === 'insertText' && typeof event.data === 'string'
}
export const createArrowLigaturesExtension = createExtension(({ editor }) => {
let literalAsciiCursor: number | null = null
return {
key: 'arrowLigatures',
mount: ({ dom, signal }) => {
const handleBeforeInput = (event: InputEvent) => {
if (!isInsertedCharacter(event)) {
return
}
const view = editor._tiptapEditor?.view ?? editor.prosemirrorView
if (!view) {
return
}
if (event.isComposing || view.composing) {
return
}
const { from, to } = view.state.selection
if (from !== to) {
return
}
const beforeText = view.state.doc.textBetween(
Math.max(0, from - PREFIX_CONTEXT_LENGTH),
from,
'',
'',
)
const resolution = resolveArrowLigatureInput({
beforeText,
cursor: from,
inputText: event.data,
literalAsciiCursor,
})
literalAsciiCursor = resolution.nextLiteralAsciiCursor
if (!resolution.change) {
return
}
event.preventDefault()
view.dispatch(
view.state.tr.insertText(
resolution.change.insert,
resolution.change.from,
resolution.change.to,
),
)
}
dom.addEventListener('beforeinput', handleBeforeInput as EventListener, {
capture: true,
signal,
})
},
} as const
})

View File

@@ -105,8 +105,23 @@ export function shouldSuppressFormattingToolbarHoverUpdate({
function getActiveFormattingToolbarFileBlockId(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
const selectedBlock =
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
let selectedBlock: ReturnType<typeof editor.getTextCursorPosition>['block'] | null = null
try {
selectedBlock = editor.getSelection()?.blocks[0] ?? null
} catch {
selectedBlock = null
}
if (!selectedBlock) {
try {
selectedBlock = editor.getTextCursorPosition().block
} catch {
selectedBlock = null
}
}
if (!selectedBlock) return null
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
? selectedBlock.id

View File

@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
import {
rememberPendingRawExitContent,
serializeEditorDocumentToMarkdown,
syncActiveTabIntoRawBuffer,
} from './editorRawModeSync'
const mockEditor = {
document: [{ id: 'paragraph-1', type: 'paragraph', content: [], props: {}, children: [] }],
blocksToMarkdownLossy: vi.fn(),
}
describe('editorRawModeSync arrow ligatures', () => {
it('keeps unicode arrows intact when rich-editor content enters raw mode', () => {
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('Flow → left ← both ↔\n')
const rawLatestContentRef = { current: null as string | null }
const result = syncActiveTabIntoRawBuffer({
editor: mockEditor as never,
activeTabPath: '/vault/flows.md',
activeTabContent: '---\ntitle: Flows\n---\n\nFlow -> left <- both <->\n',
rawLatestContentRef,
})
expect(result).toBe('---\ntitle: Flows\n---\nFlow → left ← both ↔\n')
expect(rawLatestContentRef.current).toBe(result)
})
it('keeps literal ASCII arrows intact when raw mode exits after an escaped edit', () => {
const onContentChange = vi.fn()
const rawAsciiContent = '---\ntitle: Flows\n---\nFlow <-> keeps <- and -> literal\n'
const result = rememberPendingRawExitContent({
activeTabPath: '/vault/flows.md',
activeTabContent: '---\ntitle: Flows\n---\nFlow ↔ keeps ← and → rendered\n',
rawInitialContent: '---\ntitle: Flows\n---\nFlow ↔ keeps ← and → rendered\n',
rawLatestContentRef: { current: rawAsciiContent },
onContentChange,
})
expect(result).toEqual({
path: '/vault/flows.md',
content: rawAsciiContent,
})
expect(onContentChange).toHaveBeenCalledWith('/vault/flows.md', rawAsciiContent)
})
it('serializes rich content with arrows without rewriting the characters', () => {
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('A → B, B ← C, A ↔ C\n')
expect(serializeEditorDocumentToMarkdown(
mockEditor as never,
'---\ntitle: Graph\n---\n',
)).toBe('---\ntitle: Graph\n---\nA → B, B ← C, A ↔ C\n')
})
})

View File

@@ -319,4 +319,26 @@ describe('tolariaEditorFormatting behavior', () => {
}),
}))
})
it('stays stable when BlockNote selection reads throw during inline action churn', () => {
const editor = createMockEditor('paragraph')
const selectionError = new RangeError('Index 0 out of range for <>')
editor.getSelection = vi.fn(() => {
throw selectionError
})
editor.getTextCursorPosition = vi.fn(() => {
throw selectionError
})
useBlockNoteEditorMock.mockReturnValue(editor)
expect(() => {
render(
<>
<TolariaFormattingToolbar />
<TolariaFormattingToolbarController />
</>,
)
}).not.toThrow()
})
})

View File

@@ -207,12 +207,39 @@ function editorSupportsTextStyle(
)
}
function getSelectedBlocksSafely(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
): TolariaSelectedBlock[] {
try {
const selectionBlocks = editor.getSelection()?.blocks
if (selectionBlocks?.length) {
return selectionBlocks as TolariaSelectedBlock[]
}
} catch {
// BlockNote can briefly expose an invalid selection while inline actions remount blocks.
}
try {
return [editor.getTextCursorPosition().block as TolariaSelectedBlock]
} catch {
return []
}
}
function getCursorBlockSafely(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
): TolariaSelectedBlock | null {
try {
return editor.getTextCursorPosition().block as TolariaSelectedBlock
} catch {
return null
}
}
function selectionSupportsInlineFormatting(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
return (
editor.getSelection()?.blocks || [editor.getTextCursorPosition().block]
).some((block) => block.content !== undefined)
return getSelectedBlocksSafely(editor).some((block) => block.content !== undefined)
}
function getBasicTextStyleButtonState(
@@ -274,8 +301,8 @@ function getTolariaBlockTypeSelectOptions(
function getFormattingToolbarBridgeBlockId(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
const selectedBlock =
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
const selectedBlock = getSelectedBlocksSafely(editor)[0]
if (!selectedBlock) return null
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
? selectedBlock.id
@@ -353,14 +380,15 @@ function TolariaBlockTypeSelect() {
>()
const selectedBlocks = useEditorState({
editor,
selector: ({ editor }): TolariaSelectedBlock[] =>
(editor.getSelection()?.blocks || [
editor.getTextCursorPosition().block,
]) as TolariaSelectedBlock[],
selector: ({ editor }): TolariaSelectedBlock[] => getSelectedBlocksSafely(editor),
})
const firstSelectedBlock = selectedBlocks[0]
const firstSelectedBlock = selectedBlocks[0] ?? null
const selectItems = useMemo(
() => getTolariaBlockTypeSelectOptions(editor, firstSelectedBlock),
() => (
firstSelectedBlock
? getTolariaBlockTypeSelectOptions(editor, firstSelectedBlock)
: []
),
[editor, firstSelectedBlock],
)
const selectedItem = selectItems.find(
@@ -509,7 +537,8 @@ export function TolariaFormattingToolbarController(props: {
const placement = useEditorState({
editor,
selector: ({ editor }) => {
const block = editor.getTextCursorPosition().block
const block = getCursorBlockSafely(editor)
if (!block) return 'top-start'
if (!blockHasType(block, editor, block.type, {
textAlignment: defaultProps.textAlignment,

View File

@@ -0,0 +1,93 @@
import { act } from '@testing-library/react'
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { redo, undo } from '@codemirror/commands'
import { EditorView } from '@codemirror/view'
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
const noop = () => {}
const noopCallbacks: CodeMirrorCallbacks = {
onDocChange: noop,
onCursorActivity: noop,
onSave: noop,
onEscape: () => false,
}
function applyTypedInput(view: EditorView, text: string) {
const handler = view.state.facet(EditorView.inputHandler)[0]
const selection = view.state.selection.main
const insert = () => view.state.update({
changes: { from: selection.from, to: selection.to, insert: text },
selection: { anchor: selection.from + text.length },
userEvent: 'input.type',
})
if (handler?.(view, selection.from, selection.to, text, insert)) {
return
}
view.dispatch(insert())
}
function typeSequence(view: EditorView, inputs: readonly string[]) {
act(() => {
inputs.forEach((input) => applyTypedInput(view, input))
})
}
function createView(container: HTMLDivElement) {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, '', noopCallbacks),
)
return result.current.current!
}
describe('useCodeMirror arrow ligatures', () => {
let container: HTMLDivElement
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
})
afterEach(() => {
document.body.removeChild(container)
})
it.each([
{
expected: '← → ↔',
inputs: ['<', '-', ' ', '-', '>', ' ', '<', '-', '>'],
title: 'replaces typed ASCII arrows with unicode arrows in the raw editor',
},
{
expected: '<-> -> ->',
inputs: ['\\', '<', '-', '>', ' ', '\\', '-', '>', ' ', '->'],
title: 'preserves escaped ASCII arrows and leaves paste unchanged',
},
])('$title', ({ expected, inputs }) => {
const view = createView(container)
typeSequence(view, inputs)
expect(view.state.doc.toString()).toBe(expected)
})
it('keeps undo and redo behavior natural after a ligature replacement', () => {
const view = createView(container)
typeSequence(view, ['-', '>'])
expect(view.state.doc.toString()).toBe('→')
act(() => {
undo(view)
})
expect(view.state.doc.toString()).toBe('')
act(() => {
redo(view)
})
expect(view.state.doc.toString()).toBe('→')
})
})

View File

@@ -25,7 +25,7 @@ describe('useCodeMirror', () => {
it('creates an EditorView in the container', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello world', false, noopCallbacks),
useCodeMirror(ref, 'hello world', noopCallbacks),
)
expect(result.current.current).not.toBeNull()
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
@@ -34,7 +34,7 @@ describe('useCodeMirror', () => {
it('calls requestMeasure when laputa-zoom-change event fires', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello', false, noopCallbacks),
useCodeMirror(ref, 'hello', noopCallbacks),
)
const view = result.current.current!
const spy = vi.spyOn(view, 'requestMeasure')
@@ -50,7 +50,7 @@ describe('useCodeMirror', () => {
it('stops listening for zoom changes after unmount', () => {
const ref = { current: container }
const { result, unmount } = renderHook(() =>
useCodeMirror(ref, 'hello', false, noopCallbacks),
useCodeMirror(ref, 'hello', noopCallbacks),
)
const view = result.current.current!
const spy = vi.spyOn(view, 'requestMeasure')
@@ -72,7 +72,7 @@ describe('useCodeMirror', () => {
const onDocChange = vi.fn()
const callbacks = { ...noopCallbacks, onDocChange }
const { result, rerender } = renderHook(
({ content }) => useCodeMirror(ref, content, false, callbacks),
({ content }) => useCodeMirror(ref, content, callbacks),
{ initialProps: { content: '---\ntitle: Hello\n---\nBody' } },
)
const view = result.current.current!
@@ -89,7 +89,7 @@ describe('useCodeMirror', () => {
it('does not sync when content matches current editor state', () => {
const ref = { current: container }
const { result, rerender } = renderHook(
({ content }) => useCodeMirror(ref, content, false, noopCallbacks),
({ content }) => useCodeMirror(ref, content, noopCallbacks),
{ initialProps: { content: 'hello' } },
)
const view = result.current.current!
@@ -104,7 +104,7 @@ describe('useCodeMirror', () => {
it('installs zoomCursorFix that overrides posAtCoords on the view instance', () => {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, 'hello world', false, noopCallbacks),
useCodeMirror(ref, 'hello world', noopCallbacks),
)
const view = result.current.current!
// The extension overrides posAtCoords on the instance (not the prototype)

View File

@@ -4,6 +4,7 @@ import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { frontmatterHighlightPlugin, frontmatterHighlightTheme } from '../extensions/frontmatterHighlight'
import { markdownLanguage } from '../extensions/markdownHighlight'
import { resolveArrowLigatureInput } from '../utils/arrowLigatures'
import { zoomCursorFix } from '../extensions/zoomCursorFix'
const FONT_FAMILY = '"JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -74,6 +75,38 @@ function buildSaveKeymap(callbacks: { current: CodeMirrorCallbacks }) {
}])
}
function buildArrowLigaturesExtension() {
let literalAsciiCursor: number | null = null
return EditorView.inputHandler.of((view, from, _to, text) => {
const beforeText = view.state.doc.sliceString(Math.max(0, from - 2), from)
const resolution = resolveArrowLigatureInput({
beforeText,
cursor: from,
inputText: text,
literalAsciiCursor,
})
literalAsciiCursor = resolution.nextLiteralAsciiCursor
if (!resolution.change) {
return false
}
view.dispatch({
changes: {
from: resolution.change.from,
to: resolution.change.to,
insert: resolution.change.insert,
},
selection: {
anchor: resolution.change.from + resolution.change.insert.length,
},
userEvent: 'input.type',
})
return true
})
}
export function useCodeMirror(
containerRef: React.RefObject<HTMLDivElement | null>,
content: string,
@@ -107,6 +140,7 @@ export function useCodeMirror(
highlightActiveLine(),
EditorView.lineWrapping,
history(),
buildArrowLigaturesExtension(),
keymap.of([...defaultKeymap, ...historyKeymap]),
buildSaveKeymap(callbacksRef),
buildBaseTheme(),

View File

@@ -2,7 +2,14 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDragRegion } from './useDragRegion'
const startDragging = vi.fn().mockResolvedValue(undefined)
const { invoke, startDragging } = vi.hoisted(() => ({
invoke: vi.fn().mockResolvedValue(undefined),
startDragging: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@tauri-apps/api/core', () => ({
invoke,
}))
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ startDragging }),
@@ -31,6 +38,16 @@ describe('useDragRegion', () => {
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 0 })
expect(startDragging).toHaveBeenCalledOnce()
expect(invoke).not.toHaveBeenCalled()
})
it('runs the native title-bar action on a double-click', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 0, detail: 2 })
expect(invoke).toHaveBeenCalledWith('perform_current_window_titlebar_double_click')
expect(startDragging).not.toHaveBeenCalled()
})
it('does not start dragging from no-drag containers', () => {
@@ -39,6 +56,7 @@ describe('useDragRegion', () => {
fireEvent.mouseDown(screen.getByTestId('no-drag-card'), { button: 0 })
expect(startDragging).not.toHaveBeenCalled()
expect(invoke).not.toHaveBeenCalled()
})
it('does not start dragging from interactive descendants', () => {
@@ -47,6 +65,7 @@ describe('useDragRegion', () => {
fireEvent.mouseDown(screen.getByRole('button', { name: 'Action' }), { button: 0 })
expect(startDragging).not.toHaveBeenCalled()
expect(invoke).not.toHaveBeenCalled()
})
it('ignores non-primary mouse buttons', () => {
@@ -55,5 +74,6 @@ describe('useDragRegion', () => {
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 1 })
expect(startDragging).not.toHaveBeenCalled()
expect(invoke).not.toHaveBeenCalled()
})
})

View File

@@ -1,5 +1,16 @@
import { useCallback } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { useCallback } from 'react'
const NO_DRAG_SELECTOR = 'button, input, select, a, [data-no-drag]'
function isDragDisabledTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest(NO_DRAG_SELECTOR) !== null
}
function performCurrentWindowTitlebarDoubleClick(): Promise<void> {
return invoke<void>('perform_current_window_titlebar_double_click')
}
/**
* Returns a mousedown handler that triggers Tauri window drag via startDragging().
@@ -8,10 +19,13 @@ import { getCurrentWindow } from '@tauri-apps/api/window'
export function useDragRegion() {
const onMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return
const target = e.target as HTMLElement
if (target.closest('button, input, select, a, [data-no-drag]')) return
if (isDragDisabledTarget(e.target)) return
e.preventDefault()
getCurrentWindow().startDragging().catch(() => { /* ignore */ })
if (e.detail === 2) {
void performCurrentWindowTitlebarDoubleClick().catch(() => {})
return
}
void getCurrentWindow().startDragging().catch(() => {})
}, [])
return { onMouseDown }

View File

@@ -84,4 +84,24 @@ describe('useNoteActions missing-path recovery', () => {
)
warnSpy.mockRestore()
})
it('shows a toast without reloading the vault when note content is not valid UTF-8 text', async () => {
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /test/vault/bad.csv'))
const config = makeConfig({ entries: [makeEntry({ path: '/test/vault/bad.csv', filename: 'bad.csv', title: 'bad.csv', fileKind: 'text' })] })
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
await result.current.handleSelectNote(makeEntry({ path: '/test/vault/bad.csv', filename: 'bad.csv', title: 'bad.csv', fileKind: 'text' }))
})
expect(result.current.tabs).toEqual([])
expect(result.current.activeTabPath).toBeNull()
expect(config.reloadVault).not.toHaveBeenCalled()
expect(config.setToastMessage).toHaveBeenCalledWith(
'"bad.csv" could not be opened because it is not valid UTF-8 text.',
)
warnSpy.mockRestore()
})
})

View File

@@ -201,12 +201,17 @@ function buildTabManagementOptions(
const options: {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
onMissingNotePath: (entry: VaultEntry) => void
onUnreadableNoteContent: (entry: VaultEntry) => void
} = {
onMissingNotePath: (entry) => {
const label = entry.title.trim() || entry.filename
config.setToastMessage(`"${label}" could not be opened because its file is missing or moved.`)
void config.reloadVault?.()
},
onUnreadableNoteContent: (entry) => {
const label = entry.title.trim() || entry.filename
config.setToastMessage(`"${label}" could not be opened because it is not valid UTF-8 text.`)
},
}
if (config.flushBeforeNoteSwitch) {

View File

@@ -43,6 +43,10 @@ describe('slugify', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('preserves unicode letters when building filenames', () => {
expect(slugify('停智慧')).toBe('停智慧')
})
it('removes special characters', () => {
expect(slugify('My Project! @#$%')).toBe('my-project')
})
@@ -174,6 +178,12 @@ describe('resolveNewType', () => {
expect(entry.isA).toBe('Type')
expect(content).toContain('type: Type')
})
it('uses the unicode title when the type name has no ASCII characters', () => {
const { entry } = resolveNewType({ typeName: '停智慧', vaultPath: '/vault' })
expect(entry.path).toBe('/vault/停智慧.md')
expect(entry.filename).toBe('停智慧.md')
})
})
describe('resolveTemplate', () => {
@@ -363,6 +373,25 @@ describe('useNoteCreation hook', () => {
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Briefing" because briefing.md already exists')
})
it('handleCreateType ignores an existing untitled draft when a unicode filename is unique', async () => {
const existing = makeEntry({ path: '/test/vault/untitled.md', filename: 'untitled.md', title: 'Untitled', isA: 'Note' })
const { result } = renderHook(() => useNoteCreation(makeConfig([existing]), tabDeps))
let created = false
await act(async () => {
created = await result.current.handleCreateType('停智慧')
})
expect(created).toBe(true)
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
path: '/test/vault/停智慧.md',
filename: '停智慧.md',
title: '停智慧',
isA: 'Type',
}))
expect(setToastMessage).not.toHaveBeenCalled()
})
it('createTypeEntrySilent persists without opening tab', async () => {
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
const entry = await act(async () => result.current.createTypeEntrySilent('Recipe'))

View File

@@ -25,7 +25,12 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
}
export function slugify(text: string): string {
const result = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const result = text
.normalize('NFKC')
.toLocaleLowerCase()
.trim()
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
.replace(/(^-|-$)/g, '')
return result || 'untitled'
}

View File

@@ -58,9 +58,26 @@ function mockCommands(overrides: Record<string, MockOverride> = {}) {
async function renderOnboarding(
initialVaultPath = MISSING_VAULT_PATH,
registerVault?: (
vaultPath: string,
label: string,
options?: { verifyAvailability?: boolean },
) => Promise<void>,
onTemplateVaultReady?: (vaultPath: string) => void,
) {
const rendered = renderHook(() => useOnboarding(initialVaultPath, onTemplateVaultReady))
const rendered = renderHook(() => useOnboarding(
initialVaultPath,
{
registerVault,
onVaultReady: onTemplateVaultReady
? (vaultPath, source) => {
if (source === 'template') {
onTemplateVaultReady(vaultPath)
}
}
: undefined,
},
))
await waitFor(() => {
expect(rendered.result.current.state.status).not.toBe('loading')
})
@@ -199,12 +216,13 @@ describe('useOnboarding', () => {
it('creates the template vault inside the selected parent folder', async () => {
const onTemplateVaultReady = vi.fn()
const registerVault = vi.fn().mockResolvedValue(undefined)
mockCommands({
create_getting_started_vault: (args?: MockArgs) => (args as { targetPath: string }).targetPath,
})
vi.mocked(pickFolder).mockResolvedValue(DEFAULT_PARENT_PATH)
const { result } = await renderOnboarding(MISSING_VAULT_PATH, onTemplateVaultReady)
const { result } = await renderOnboarding(MISSING_VAULT_PATH, registerVault, onTemplateVaultReady)
await expectStatus(result, 'welcome')
await act(async () => {
@@ -215,6 +233,11 @@ describe('useOnboarding', () => {
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', {
targetPath: DEFAULT_GETTING_STARTED_PATH,
})
expect(registerVault).toHaveBeenCalledWith(
DEFAULT_GETTING_STARTED_PATH,
'Getting Started',
{ verifyAvailability: false },
)
expect(onTemplateVaultReady).toHaveBeenCalledWith(DEFAULT_GETTING_STARTED_PATH)
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
})
@@ -298,10 +321,11 @@ describe('useOnboarding', () => {
})
it('opens an existing folder and transitions to ready', async () => {
const registerVault = vi.fn().mockResolvedValue(undefined)
mockCommands()
vi.mocked(pickFolder).mockResolvedValue('/selected/folder')
const { result } = await renderOnboarding()
const { result } = await renderOnboarding(MISSING_VAULT_PATH, registerVault)
await expectStatus(result, 'welcome')
await act(async () => {
@@ -309,9 +333,26 @@ describe('useOnboarding', () => {
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/selected/folder' })
expect(registerVault).toHaveBeenCalledWith('/selected/folder', 'folder')
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
})
it('shows a visible error when vault registration fails during onboarding', async () => {
const registerVault = vi.fn().mockRejectedValue(new Error('Failed to write vault list'))
mockCommands()
vi.mocked(pickFolder).mockResolvedValue('/selected/folder')
const { result } = await renderOnboarding(MISSING_VAULT_PATH, registerVault)
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleOpenFolder()
})
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: DEFAULT_GETTING_STARTED_PATH })
expect(result.current.error).toBe('Could not open vault: Failed to write vault list')
})
it('does nothing when the open-folder picker is cancelled', async () => {
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleOpenFolder()

View File

@@ -2,7 +2,11 @@ import { useCallback, useEffect, useState, type Dispatch, type SetStateAction }
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
import { buildGettingStartedVaultPath, formatGettingStartedCloneError } from '../utils/gettingStartedVault'
import {
buildGettingStartedVaultPath,
formatGettingStartedCloneError,
labelFromPath,
} from '../utils/gettingStartedVault'
import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog'
type OnboardingState =
@@ -12,20 +16,39 @@ type OnboardingState =
| { status: 'ready'; vaultPath: string }
type CreatingAction = 'template' | 'empty' | null
type ReadyVaultSource = 'template' | 'empty' | 'existing'
type OnVaultReady = (vaultPath: string, source: ReadyVaultSource) => void
type RegisterVault = (
vaultPath: string,
label: string,
options?: { verifyAvailability?: boolean },
) => Promise<void>
type SetError = Dispatch<SetStateAction<string | null>>
type SetCreatingAction = Dispatch<SetStateAction<CreatingAction>>
interface ReadyVaultHandlers {
setState: Dispatch<SetStateAction<OnboardingState>>
setUserReadyVaultPath: Dispatch<SetStateAction<string | null>>
}
interface TemplateVaultCreationOptions {
handlers: ReadyVaultHandlers
setState: Dispatch<SetStateAction<OnboardingState>>
setCreatingAction: SetCreatingAction
setError: SetError
setLastTemplatePath: Dispatch<SetStateAction<string | null>>
onTemplateVaultReady?: (vaultPath: string) => void
registerVault?: RegisterVault
onVaultReady?: OnVaultReady
}
interface OnboardingOptions {
onVaultReady?: OnVaultReady
registerVault?: RegisterVault
}
interface ReadyVaultHandlerOptions {
onVaultReady?: OnVaultReady
registerVault?: RegisterVault
setError: SetError
setState: Dispatch<SetStateAction<OnboardingState>>
}
interface CreateEmptyVaultHandlerOptions extends ReadyVaultHandlerOptions {
setCreatingAction: SetCreatingAction
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
@@ -74,19 +97,57 @@ async function clearMissingActiveVault(missingPath: string): Promise<boolean> {
}
function markVaultReady(
handlers: ReadyVaultHandlers,
setState: Dispatch<SetStateAction<OnboardingState>>,
vaultPath: string,
) {
markDismissed()
handlers.setState({ status: 'ready', vaultPath })
handlers.setUserReadyVaultPath(vaultPath)
setState({ status: 'ready', vaultPath })
}
async function pickFolderWithOnboardingError(
title: string,
setError: SetError,
action: string,
): Promise<string | null> {
function formatOnboardingRegistrationError({
action,
err,
}: {
action: string
err: unknown
}): string {
const message =
typeof err === 'string'
? err
: err instanceof Error
? err.message
: `${err}`
return message ? `${action}: ${message}` : action
}
async function registerVaultSelection(
registerVault: RegisterVault | undefined,
vaultPath: string,
options?: { verifyAvailability?: boolean },
): Promise<void> {
if (!registerVault) {
return
}
const label = labelFromPath(vaultPath)
if (options) {
await registerVault(vaultPath, label, options)
return
}
await registerVault(vaultPath, label)
}
async function pickFolderWithOnboardingError({
action,
setError,
title,
}: {
action: string
setError: SetError
title: string
}): Promise<string | null> {
setError(null)
try {
@@ -107,8 +168,17 @@ function useTemplateVaultCreation(
try {
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
markVaultReady(options.handlers, vaultPath)
options.onTemplateVaultReady?.(vaultPath)
try {
await registerVaultSelection(options.registerVault, vaultPath, { verifyAvailability: false })
} catch (err) {
options.setError(formatOnboardingRegistrationError({
action: 'Could not register the Getting Started vault',
err,
}))
return
}
markVaultReady(options.setState, vaultPath)
options.onVaultReady?.(vaultPath, 'template')
} catch (err) {
options.setError(formatGettingStartedCloneError(err))
} finally {
@@ -122,11 +192,11 @@ function useCreateVaultHandler(
setError: SetError,
) {
return useCallback(async () => {
const parentPath = await pickFolderWithOnboardingError(
'Choose a parent folder for the Getting Started vault',
const parentPath = await pickFolderWithOnboardingError({
action: 'Could not choose a parent folder',
setError,
'Could not choose a parent folder',
)
title: 'Choose a parent folder for the Getting Started vault',
})
if (!parentPath) return
await createTemplateVault(buildGettingStartedVaultPath(parentPath))
@@ -134,57 +204,73 @@ function useCreateVaultHandler(
}
function useCreateEmptyVaultHandler(
handlers: ReadyVaultHandlers,
setCreatingAction: SetCreatingAction,
setError: SetError,
options: CreateEmptyVaultHandlerOptions,
) {
return useCallback(async () => {
const path = await pickFolderWithOnboardingError(
'Choose where to create your vault',
setError,
'Could not choose where to create your vault',
)
const path = await pickFolderWithOnboardingError({
action: 'Could not choose where to create your vault',
setError: options.setError,
title: 'Choose where to create your vault',
})
if (!path) return
try {
setCreatingAction('empty')
options.setCreatingAction('empty')
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
markVaultReady(handlers, vaultPath)
try {
await registerVaultSelection(options.registerVault, vaultPath, { verifyAvailability: false })
} catch (err) {
options.setError(formatOnboardingRegistrationError({
action: 'Could not register the new vault',
err,
}))
return
}
markVaultReady(options.setState, vaultPath)
options.onVaultReady?.(vaultPath, 'empty')
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
options.setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
} finally {
setCreatingAction(null)
options.setCreatingAction(null)
}
}, [handlers, setCreatingAction, setError])
}, [options])
}
function useOpenFolderHandler(
handlers: ReadyVaultHandlers,
setError: SetError,
options: ReadyVaultHandlerOptions,
) {
return useCallback(async () => {
const path = await pickFolderWithOnboardingError(
'Open vault folder',
setError,
'Failed to open folder',
)
const path = await pickFolderWithOnboardingError({
action: 'Failed to open folder',
setError: options.setError,
title: 'Open vault folder',
})
if (!path) return
markVaultReady(handlers, path)
}, [handlers, setError])
try {
await registerVaultSelection(options.registerVault, path)
} catch (err) {
options.setError(formatOnboardingRegistrationError({
action: 'Could not open vault',
err,
}))
return
}
markVaultReady(options.setState, path)
options.onVaultReady?.(path, 'existing')
}, [options])
}
export function useOnboarding(
initialVaultPath: string,
onTemplateVaultReady?: (vaultPath: string) => void,
options: OnboardingOptions = {},
initialVaultResolved = true,
) {
const [state, setState] = useState<OnboardingState>({ status: 'loading' })
const [creatingAction, setCreatingAction] = useState<CreatingAction>(null)
const [error, setError] = useState<string | null>(null)
const [lastTemplatePath, setLastTemplatePath] = useState<string | null>(null)
const [userReadyVaultPath, setUserReadyVaultPath] = useState<string | null>(null)
const readyVaultHandlers = { setState, setUserReadyVaultPath }
useEffect(() => {
let cancelled = false
@@ -225,11 +311,12 @@ export function useOnboarding(
}, [initialVaultPath, initialVaultResolved])
const createTemplateVault = useTemplateVaultCreation({
handlers: readyVaultHandlers,
setState,
setCreatingAction,
setError,
setLastTemplatePath,
onTemplateVaultReady,
registerVault: options.registerVault,
onVaultReady: options.onVaultReady,
})
const handleCreateVault = useCreateVaultHandler(createTemplateVault, setError)
@@ -239,13 +326,20 @@ export function useOnboarding(
await createTemplateVault(lastTemplatePath)
}, [createTemplateVault, lastTemplatePath])
const handleCreateEmptyVault = useCreateEmptyVaultHandler(
readyVaultHandlers,
const handleCreateEmptyVault = useCreateEmptyVaultHandler({
onVaultReady: options.onVaultReady,
registerVault: options.registerVault,
setCreatingAction,
setError,
)
setState,
})
const handleOpenFolder = useOpenFolderHandler(readyVaultHandlers, setError)
const handleOpenFolder = useOpenFolderHandler({
onVaultReady: options.onVaultReady,
registerVault: options.registerVault,
setError,
setState,
})
const handleDismiss = useCallback(() => {
markDismissed()
@@ -265,6 +359,5 @@ export function useOnboarding(
handleCreateEmptyVault,
handleOpenFolder,
handleDismiss,
userReadyVaultPath,
}
}

View File

@@ -160,6 +160,42 @@ describe('useTabManagement (single-note model)', () => {
)
warnSpy.mockRestore()
})
it('returns to the empty state when note content is not valid UTF-8 text', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('File is not valid UTF-8 text: /vault/note/bad.csv'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const onUnreadableNoteContent = vi.fn()
const { result } = renderHook(() => useTabManagement({ onUnreadableNoteContent }))
await selectNote(result, {
path: '/vault/note/bad.csv',
filename: 'bad.csv',
title: 'bad.csv',
fileKind: 'text',
})
expect(result.current.tabs).toEqual([])
expect(result.current.activeTabPath).toBeNull()
expect(onUnreadableNoteContent).toHaveBeenCalledWith(
expect.objectContaining({ path: '/vault/note/bad.csv', title: 'bad.csv' }),
expect.any(Error),
)
warnSpy.mockRestore()
})
it('returns to the empty state when no active vault is selected', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('No active vault selected'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/note/orphaned.md', title: 'Orphaned Note' })
expect(result.current.tabs).toEqual([])
expect(result.current.activeTabPath).toBeNull()
warnSpy.mockRestore()
})
})
describe('handleReplaceActiveTab', () => {
@@ -328,6 +364,24 @@ describe('useTabManagement (single-note model)', () => {
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
})
it('swallows no-active-vault prefetch failures and lets a later open recover', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)
.mockRejectedValueOnce(new Error('No active vault selected'))
.mockResolvedValueOnce('# Recovered content')
prefetchNoteContent('/vault/note/recovered.md')
await vi.waitFor(() => expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1))
await Promise.resolve()
await Promise.resolve()
const { result } = renderHook(() => useTabManagement())
await selectNote(result, { path: '/vault/note/recovered.md', title: 'Recovered' })
expect(result.current.tabs[0].content).toBe('# Recovered content')
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(2)
})
it('serves refreshed cached content after a save replaces stale prefetched data', async () => {
const mockInvoke = await prefetchResolvedContent('/vault/note/saved.md', '# Stale prefetched content')
vi.mocked(mockInvoke).mockResolvedValue('# Persisted content')

View File

@@ -73,7 +73,10 @@ function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): Note
* Cache is short-lived: cleared on vault reload via clearPrefetchCache(). */
export function prefetchNoteContent(path: string): void {
if (prefetchCache.has(path)) return
requestNoteContent({ path })
void requestNoteContent({ path }).promise.catch((error) => {
if (isNoActiveVaultSelectedError(error) || isUnreadableNoteContentError(error)) return
console.warn('Failed to prefetch note content:', error)
})
}
export function cacheNoteContent(path: string, content: string): void {
@@ -103,6 +106,7 @@ export type { Tab }
interface TabManagementOptions {
beforeNavigate?: (fromPath: string, toPath: string) => Promise<void>
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}
function syncActiveTabPath(
@@ -189,6 +193,24 @@ function isMissingNotePathError(error: unknown): boolean {
return /does not exist|not found|enoent/i.test(message)
}
function isNoActiveVaultSelectedError(error: unknown): boolean {
const message = error instanceof Error
? error.message
: typeof error === 'string'
? error
: String(error)
return /no active vault selected/i.test(message)
}
function isUnreadableNoteContentError(error: unknown): boolean {
const message = error instanceof Error
? error.message
: typeof error === 'string'
? error
: String(error)
return /not valid utf-8 text|invalid utf-8|stream did not contain valid utf-8/i.test(message)
}
function shouldApplyLoadedEntry(options: {
seq: number
navSeqRef: React.MutableRefObject<number>
@@ -213,6 +235,99 @@ function shouldApplyLoadedEntry(options: {
return cachedContent !== content || !pathsMatch(activeTabPathRef.current, path)
}
type EntryLoadFailureKind =
| 'missing-active-vault'
| 'missing-path'
| 'unreadable-content'
| 'load-failed'
type RecoverableEntryLoadFailureKind = Exclude<EntryLoadFailureKind, 'load-failed'>
function getEntryLoadFailureKind(error: unknown): EntryLoadFailureKind {
if (isNoActiveVaultSelectedError(error)) return 'missing-active-vault'
if (isMissingNotePathError(error)) return 'missing-path'
if (isUnreadableNoteContentError(error)) return 'unreadable-content'
return 'load-failed'
}
function resetFailedEntrySelection(options: {
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
}) {
const { tabsRef, activeTabPathRef, setTabs, setActiveTabPath } = options
clearTabs(tabsRef, setTabs)
syncActiveTabPath(activeTabPathRef, setActiveTabPath, null)
}
function runEntryFailureCallback(options: {
callback?: (entry: VaultEntry, error: unknown) => void | Promise<void>
entry: VaultEntry
error: unknown
warning: string
}) {
const { callback, entry, error, warning } = options
Promise.resolve(callback?.(entry, error)).catch((callbackError) => {
console.warn(warning, callbackError)
})
}
function handleRecoverableEntryLoadFailure(options: {
kind: RecoverableEntryLoadFailureKind
entry: VaultEntry
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
error: unknown
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}) {
const {
kind,
entry,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
error,
onMissingNotePath,
onUnreadableNoteContent,
} = options
if (kind === 'missing-active-vault') {
clearPrefetchCache()
}
resetFailedEntrySelection({
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
failNoteOpenTrace(entry.path, kind)
if (kind === 'missing-path') {
runEntryFailureCallback({
callback: onMissingNotePath,
entry,
error,
warning: 'Failed to handle missing note path:',
})
return
}
if (kind === 'unreadable-content') {
runEntryFailureCallback({
callback: onUnreadableNoteContent,
entry,
error,
warning: 'Failed to handle unreadable note content:',
})
}
}
function handleEntryLoadFailure(options: {
entry: VaultEntry
seq: number
@@ -223,6 +338,7 @@ function handleEntryLoadFailure(options: {
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
error: unknown
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}) {
const {
entry,
@@ -234,19 +350,28 @@ function handleEntryLoadFailure(options: {
setActiveTabPath,
error,
onMissingNotePath,
onUnreadableNoteContent,
} = options
console.warn('Failed to load note content:', error)
if (navSeqRef.current !== seq) return
if (isMissingNotePathError(error)) {
clearTabs(tabsRef, setTabs)
syncActiveTabPath(activeTabPathRef, setActiveTabPath, null)
failNoteOpenTrace(entry.path, 'missing-path')
Promise.resolve(onMissingNotePath?.(entry, error)).catch((callbackError) => {
console.warn('Failed to handle missing note path:', callbackError)
const failureKind = getEntryLoadFailureKind(error)
if (failureKind !== 'load-failed') {
handleRecoverableEntryLoadFailure({
kind: failureKind,
entry,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
error,
onMissingNotePath,
onUnreadableNoteContent,
})
return
}
setSingleTab(tabsRef, setTabs, { entry, content: '' })
failNoteOpenTrace(entry.path, 'load-failed')
}
@@ -260,6 +385,7 @@ async function navigateToEntry(options: {
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
}) {
const {
entry,
@@ -270,6 +396,7 @@ async function navigateToEntry(options: {
setTabs,
setActiveTabPath,
onMissingNotePath,
onUnreadableNoteContent,
} = options
if (entry.fileKind === 'binary') {
@@ -319,6 +446,7 @@ async function navigateToEntry(options: {
setActiveTabPath,
error: err,
onMissingNotePath,
onUnreadableNoteContent,
})
}
}
@@ -337,6 +465,7 @@ export function useTabManagement(options: TabManagementOptions = {}) {
const beforeNavigateSeqRef = useRef(0)
const beforeNavigate = options.beforeNavigate
const onMissingNotePath = options.onMissingNotePath
const onUnreadableNoteContent = options.onUnreadableNoteContent
const executeNavigationWithBoundary = useCallback(async (
targetPath: string,
@@ -372,8 +501,9 @@ export function useTabManagement(options: TabManagementOptions = {}) {
setTabs,
setActiveTabPath,
onMissingNotePath,
onUnreadableNoteContent,
}))
}, [executeNavigationWithBoundary, onMissingNotePath])
}, [executeNavigationWithBoundary, onMissingNotePath, onUnreadableNoteContent])
const handleSwitchTab = useCallback((path: string) => {
syncActiveTabPath(activeTabPathRef, setActiveTabPath, path)
@@ -400,8 +530,9 @@ export function useTabManagement(options: TabManagementOptions = {}) {
setTabs,
setActiveTabPath,
onMissingNotePath,
onUnreadableNoteContent,
}))
}, [executeNavigationWithBoundary, onMissingNotePath])
}, [executeNavigationWithBoundary, onMissingNotePath, onUnreadableNoteContent])
const closeAllTabs = useCallback(() => {
tabsRef.current = []

View File

@@ -157,6 +157,43 @@ describe('useVaultSwitcher', () => {
})
})
it('registers an onboarding vault selection before switching to it', async () => {
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.registerVaultSelection('/selected/vault', 'Selected Vault')
})
expect(result.current.vaultPath).toBe('/selected/vault')
expect(result.current.selectedVaultPath).toBe('/selected/vault')
expect(mockVaultListStore).toEqual({
vaults: [{ label: 'Selected Vault', path: '/selected/vault' }],
active_vault: '/selected/vault',
hidden_defaults: [],
})
})
it('registers the canonical Getting Started vault without persisting a duplicate entry', async () => {
setMockInvokeBehavior({
checkVaultExists: ({ path }) => path === expectedDefaultVaultPath,
})
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.registerVaultSelection(expectedDefaultVaultPath, 'Getting Started')
})
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.selectedVaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.allVaults).toEqual([{ label: 'Getting Started', path: expectedDefaultVaultPath }])
expect(mockVaultListStore).toEqual({
vaults: [],
active_vault: expectedDefaultVaultPath,
hidden_defaults: [],
})
})
it('persists active vault when switching', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],

View File

@@ -31,6 +31,7 @@ interface PersistedVaultState {
defaultPath: string
extraVaults: VaultOption[]
hiddenDefaults: string[]
lastPersistedSnapshotRef: MutableRefObject<string | null>
loaded: boolean
selectedVaultPath: string | null
setDefaultAvailable: Dispatch<SetStateAction<boolean>>
@@ -52,6 +53,7 @@ interface PersistedVaultStore {
defaultPath: string
extraVaults: VaultOption[]
hiddenDefaults: string[]
lastPersistedSnapshotRef: MutableRefObject<string | null>
loaded: boolean
selectedVaultPath: string | null
setDefaultAvailable: Dispatch<SetStateAction<boolean>>
@@ -69,6 +71,17 @@ interface VaultActionOptions extends PersistedVaultState, VaultCollections {
onToastRef: MutableRefObject<(msg: string) => void>
}
interface RegisteredVaultSelection {
nextDefaultAvailable: boolean
nextExtraVaults: VaultOption[]
nextHiddenDefaults: string[]
nextSelectedVaultPath: string
}
interface RegisterVaultSelectionOptions {
verifyAvailability?: boolean
}
interface RestoreGettingStartedOptions {
defaultPath: string
onToastRef: MutableRefObject<(msg: string) => void>
@@ -121,6 +134,18 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
function serializePersistedVaultSnapshot(
vaults: VaultOption[],
activeVault: string | null,
hiddenDefaults: string[],
): string {
return JSON.stringify({
activeVault,
hiddenDefaults,
vaults: vaults.map(({ label, path }) => ({ label, path })),
})
}
async function resolveDefaultPath(): Promise<string> {
if (STATIC_DEFAULT_PATH) {
return STATIC_DEFAULT_PATH
@@ -178,13 +203,19 @@ async function loadInitialVaultState() {
console.warn('Failed to load vault list:', vaultListResult.reason)
}
return sanitizeCanonicalGettingStartedState({
const sanitizedState = sanitizeCanonicalGettingStartedState({
activeVault,
defaultAvailable,
hiddenDefaults,
resolvedDefaultPath,
vaults,
})
const persistedSnapshot = serializePersistedVaultSnapshot(vaults, activeVault, hiddenDefaults)
return {
...sanitizedState,
persistedSnapshot,
}
}
function sanitizeCanonicalGettingStartedState({
@@ -390,9 +421,10 @@ function useLoadPersistedVaultState(
let cancelled = false
loadInitialVaultState()
.then(({ activeVault, defaultAvailable, hiddenDefaults: hidden, resolvedDefaultPath, vaults }) => {
.then(({ activeVault, defaultAvailable, hiddenDefaults: hidden, persistedSnapshot, resolvedDefaultPath, vaults }) => {
if (cancelled) return
store.lastPersistedSnapshotRef.current = persistedSnapshot
setExtraVaults(vaults)
setHiddenDefaults(hidden)
applyResolvedDefaultPath({
@@ -420,15 +452,25 @@ function useLoadPersistedVaultState(
}
function usePersistedVaultStorage(store: PersistedVaultStore) {
const { extraVaults, hiddenDefaults, loaded, selectedVaultPath } = store
const { extraVaults, hiddenDefaults, lastPersistedSnapshotRef, loaded, selectedVaultPath } = store
useEffect(() => {
if (!loaded) return
saveVaultList(extraVaults, selectedVaultPath, hiddenDefaults).catch(err =>
console.warn('Failed to persist vault list:', err),
)
}, [extraVaults, hiddenDefaults, loaded, selectedVaultPath])
const snapshot = serializePersistedVaultSnapshot(extraVaults, selectedVaultPath, hiddenDefaults)
if (lastPersistedSnapshotRef.current === snapshot) {
return
}
saveVaultList(extraVaults, selectedVaultPath, hiddenDefaults)
.then(() => {
lastPersistedSnapshotRef.current = snapshot
})
.catch(err => {
console.warn('Failed to persist vault list:', err)
})
}, [extraVaults, hiddenDefaults, lastPersistedSnapshotRef, loaded, selectedVaultPath])
}
function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): PersistedVaultState {
@@ -437,6 +479,7 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
const [defaultAvailable, setDefaultAvailable] = useState(false)
const lastPersistedSnapshotRef = useRef<string | null>(null)
const [loaded, setLoaded] = useState(false)
const [defaultPath, setDefaultPath] = useState(STATIC_DEFAULT_PATH)
@@ -445,6 +488,7 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
defaultPath,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
loaded,
selectedVaultPath,
setDefaultAvailable,
@@ -465,6 +509,7 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
defaultPath,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
loaded,
selectedVaultPath,
setDefaultAvailable,
@@ -535,6 +580,102 @@ function addVaultToList({
})
}
function upsertAvailableVaultOption(
extraVaults: VaultOption[],
path: string,
label: string,
): VaultOption[] {
const existingVault = extraVaults.find((vault) => vault.path === path)
if (!existingVault) {
return [...extraVaults, { label, path, available: true }]
}
return extraVaults.map((vault) => (
vault.path === path
? { ...vault, label: vault.label || label, available: true }
: vault
))
}
function buildRegisteredVaultSelection({
defaultAvailable,
defaultPath,
extraVaults,
hiddenDefaults,
label,
path,
}: {
defaultAvailable: boolean
defaultPath: string
extraVaults: VaultOption[]
hiddenDefaults: string[]
label: string
path: string
}): RegisteredVaultSelection {
const isCanonicalDefaultVault = path === defaultPath && defaultPath.length > 0
return {
nextDefaultAvailable: isCanonicalDefaultVault ? true : defaultAvailable,
nextExtraVaults: isCanonicalDefaultVault
? extraVaults.filter((vault) => vault.path !== path)
: upsertAvailableVaultOption(extraVaults, path, label),
nextHiddenDefaults: isCanonicalDefaultVault
? hiddenDefaults.filter((hiddenPath) => hiddenPath !== path)
: hiddenDefaults,
nextSelectedVaultPath: path,
}
}
async function persistRegisteredVaultSelection({
hiddenDefaults,
lastPersistedSnapshotRef,
selectedVaultPath,
vaults,
}: {
hiddenDefaults: string[]
lastPersistedSnapshotRef: MutableRefObject<string | null>
selectedVaultPath: string
vaults: VaultOption[]
}): Promise<void> {
const nextSnapshot = serializePersistedVaultSnapshot(
vaults,
selectedVaultPath,
hiddenDefaults,
)
await saveVaultList(vaults, selectedVaultPath, hiddenDefaults)
lastPersistedSnapshotRef.current = nextSnapshot
}
function applyRegisteredVaultSelection({
nextDefaultAvailable,
nextExtraVaults,
nextHiddenDefaults,
nextSelectedVaultPath,
onSwitchRef,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
}: RegisteredVaultSelection & {
onSwitchRef: MutableRefObject<() => void>
setDefaultAvailable: Dispatch<SetStateAction<boolean>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
}) {
setDefaultAvailable(nextDefaultAvailable)
setExtraVaults(nextExtraVaults)
setHiddenDefaults(nextHiddenDefaults)
switchVaultPath({
setSelectedVaultPath,
setVaultPath,
onSwitchRef,
path: nextSelectedVaultPath,
})
}
function switchVaultPath({
setSelectedVaultPath,
setVaultPath,
@@ -552,6 +693,13 @@ function switchVaultPath({
onSwitchRef.current()
}
async function ensureVaultCanBeRegistered(path: string): Promise<void> {
const exists = await checkVaultAvailability(path)
if (!exists) {
throw new Error('Selected folder is not available')
}
}
function listRemainingVaults({
defaultVaults,
extraVaults,
@@ -645,6 +793,129 @@ function useVaultClonedAction(
}, [addAndSwitch, onToastRef])
}
function useRegisterVaultSelectionAction({
defaultAvailable,
defaultPath,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
onSwitchRef,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
}: {
defaultAvailable: boolean
defaultPath: string
extraVaults: VaultOption[]
hiddenDefaults: string[]
lastPersistedSnapshotRef: MutableRefObject<string | null>
onSwitchRef: MutableRefObject<() => void>
setDefaultAvailable: Dispatch<SetStateAction<boolean>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
}) {
return useCallback(async (path: string, label: string, options: RegisterVaultSelectionOptions = {}) => {
if (options.verifyAvailability !== false) {
await ensureVaultCanBeRegistered(path)
}
const nextSelection = buildRegisteredVaultSelection({
defaultAvailable,
defaultPath,
extraVaults,
hiddenDefaults,
label,
path,
})
await persistRegisteredVaultSelection({
hiddenDefaults: nextSelection.nextHiddenDefaults,
lastPersistedSnapshotRef,
selectedVaultPath: nextSelection.nextSelectedVaultPath,
vaults: nextSelection.nextExtraVaults,
})
applyRegisteredVaultSelection({
...nextSelection,
onSwitchRef,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
})
}, [
defaultAvailable,
defaultPath,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
onSwitchRef,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
])
}
function useSyncVaultSelectionAction({
defaultAvailable,
defaultPath,
extraVaults,
hiddenDefaults,
onSwitchRef,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
}: {
defaultAvailable: boolean
defaultPath: string
extraVaults: VaultOption[]
hiddenDefaults: string[]
onSwitchRef: MutableRefObject<() => void>
setDefaultAvailable: Dispatch<SetStateAction<boolean>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
}) {
return useCallback((path: string, label: string) => {
const nextSelection = buildRegisteredVaultSelection({
defaultAvailable,
defaultPath,
extraVaults,
hiddenDefaults,
label,
path,
})
applyRegisteredVaultSelection({
...nextSelection,
onSwitchRef,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
})
}, [
defaultAvailable,
defaultPath,
extraVaults,
hiddenDefaults,
onSwitchRef,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
])
}
function useOpenLocalFolderAction(
addAndSwitch: (path: string, label: string) => void,
onToastRef: MutableRefObject<(msg: string) => void>,
@@ -752,10 +1023,12 @@ function useRestoreGettingStartedAction(options: RestoreGettingStartedOptions) {
}
function useVaultActions({
defaultAvailable,
defaultPath,
defaultVaults,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
onSwitchRef,
onToastRef,
setDefaultAvailable,
@@ -771,6 +1044,31 @@ function useVaultActions({
}, [setExtraVaults])
const switchVault = useSwitchVaultAction(onSwitchRef, setSelectedVaultPath, setVaultPath)
const registerVaultSelection = useRegisterVaultSelectionAction({
defaultAvailable,
defaultPath,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
onSwitchRef,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
})
const syncVaultSelection = useSyncVaultSelectionAction({
defaultAvailable,
defaultPath,
extraVaults,
hiddenDefaults,
onSwitchRef,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
})
const addAndSwitch = useCallback((path: string, label: string) => {
addVault(path, label)
switchVault(path)
@@ -780,6 +1078,7 @@ function useVaultActions({
handleCreateEmptyVault: useCreateEmptyVaultAction(addAndSwitch, onToastRef),
handleOpenLocalFolder: useOpenLocalFolderAction(addAndSwitch, onToastRef),
handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef),
registerVaultSelection,
removeVault: useRemoveVaultAction({
defaultVaults,
extraVaults,
@@ -800,6 +1099,7 @@ function useVaultActions({
setHiddenDefaults,
switchVault,
}),
syncVaultSelection,
switchVault,
}
}
@@ -850,7 +1150,16 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
hiddenDefaults,
extraVaults,
)
const { handleCreateEmptyVault, handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
const {
handleCreateEmptyVault,
handleOpenLocalFolder,
handleVaultCloned,
registerVaultSelection,
removeVault,
restoreGettingStarted,
syncVaultSelection,
switchVault,
} = useVaultActions({
...persistedState,
allVaults,
defaultVaults,
@@ -867,9 +1176,11 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
handleVaultCloned,
isGettingStartedHidden,
loaded,
registerVaultSelection,
removeVault,
restoreGettingStarted,
selectedVaultPath,
syncVaultSelection,
switchVault,
vaultPath,
}

View File

@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TableHandlesView } from '../../node_modules/@blocknote/core/src/extensions/TableHandles/TableHandles'
function createTableBlock() {
return {
id: 'table-block',
type: 'table',
content: {
type: 'tableContent',
rows: [
{ cells: ['Head 1', 'Head 2'] },
{ cells: ['A', 'B'] },
],
},
}
}
describe('BlockNote table handles regression', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('hides stale table handles instead of throwing when tbody is missing during update', () => {
const block = createTableBlock()
const editorRoot = document.createElement('div')
document.body.appendChild(editorRoot)
const editor = {
getBlock: vi.fn(() => block),
}
const emitUpdate = vi.fn()
const view = new TableHandlesView(
editor as never,
{
dom: editorRoot,
root: document,
} as never,
emitUpdate,
)
view.state = {
block,
show: true,
showAddOrRemoveRowsButton: true,
showAddOrRemoveColumnsButton: true,
rowIndex: 0,
colIndex: 0,
} as never
const staleTableWrapper = document.createElement('div')
editorRoot.appendChild(staleTableWrapper)
view.tableElement = staleTableWrapper
expect(() => view.update()).not.toThrow()
expect(view.state?.show).toBe(false)
expect(view.state?.showAddOrRemoveRowsButton).toBe(false)
expect(view.state?.showAddOrRemoveColumnsButton).toBe(false)
expect(emitUpdate).toHaveBeenCalled()
view.destroy()
})
})

View File

@@ -75,7 +75,7 @@ describe('main entrypoint', () => {
rootOptions().onCaughtError?.(error, { componentStack: '\n in App' })
expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '\n in App' })
})
}, 10_000)
it('normalizes missing React component stacks before handing errors to Sentry', async () => {
await importEntrypoint()

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { resolveArrowLigatureInput } from './arrowLigatures'
describe('resolveArrowLigatureInput', () => {
it.each([
{
expected: {
change: { from: 0, insert: '→', to: 1 },
nextLiteralAsciiCursor: null,
},
input: {
beforeText: '-',
cursor: 1,
inputText: '>',
literalAsciiCursor: null,
},
title: 'replaces the completed right-arrow sequence',
},
{
expected: {
change: { from: 0, insert: '←', to: 1 },
nextLiteralAsciiCursor: null,
},
input: {
beforeText: '<',
cursor: 1,
inputText: '-',
literalAsciiCursor: null,
},
title: 'replaces the completed left-arrow sequence',
},
{
expected: {
change: { from: 0, insert: '↔', to: 2 },
nextLiteralAsciiCursor: null,
},
input: {
beforeText: '<-',
cursor: 2,
inputText: '>',
literalAsciiCursor: null,
},
title: 'prefers the left-right arrow over a partial right-arrow replacement',
},
{
expected: {
change: { from: 0, insert: '↔', to: 1 },
nextLiteralAsciiCursor: null,
},
input: {
beforeText: '←',
cursor: 1,
inputText: '>',
literalAsciiCursor: null,
},
title: 'upgrades a just-created left arrow when > completes the sequence',
},
{
expected: {
change: { from: 0, insert: '->', to: 2 },
nextLiteralAsciiCursor: null,
},
input: {
beforeText: '\\-',
cursor: 2,
inputText: '>',
literalAsciiCursor: null,
},
title: 'keeps escaped right arrows as ASCII',
},
{
expected: {
change: { from: 0, insert: '<-', to: 2 },
nextLiteralAsciiCursor: 2,
},
input: {
beforeText: '\\<',
cursor: 2,
inputText: '-',
literalAsciiCursor: null,
},
title: 'keeps escaped left arrows as ASCII and tracks the pending <-> escape',
},
{
expected: {
change: null,
nextLiteralAsciiCursor: null,
},
input: {
beforeText: '<-',
cursor: 2,
inputText: '>',
literalAsciiCursor: 2,
},
title: 'lets the next > through unchanged after an escaped <- sequence',
},
{
expected: {
change: { from: 3, insert: '→', to: 4 },
nextLiteralAsciiCursor: null,
},
input: {
beforeText: '-',
cursor: 4,
inputText: '>',
literalAsciiCursor: 2,
},
title: 'clears stale escape state when the next input goes elsewhere',
},
{
expected: {
change: null,
nextLiteralAsciiCursor: null,
},
input: {
beforeText: '-',
cursor: 1,
inputText: '->',
literalAsciiCursor: null,
},
title: 'ignores multi-character input so paste stays literal',
},
])('$title', ({ expected, input }) => {
expect(resolveArrowLigatureInput(input)).toEqual(expected)
})
})

140
src/utils/arrowLigatures.ts Normal file
View File

@@ -0,0 +1,140 @@
export interface ArrowLigatureChange {
from: number
insert: string
to: number
}
export interface ArrowLigatureResolution {
change: ArrowLigatureChange | null
nextLiteralAsciiCursor: number | null
}
interface ResolveArrowLigatureInputParams {
beforeText: string
cursor: number
inputText: string
literalAsciiCursor: number | null
}
interface ArrowLigatureRule {
insert: string
nextLiteralAsciiCursor: number | null
prefix: string
}
const ESCAPED_LEFT_ARROW_PREFIX = '\\<'
const ESCAPED_RIGHT_ARROW_PREFIX = '\\-'
const LEFT_ARROW = '←'
const LEFT_ARROW_PREFIX = '<'
const LEFT_RIGHT_ARROW = '↔'
const LEFT_RIGHT_ARROW_PREFIX = '<-'
const RIGHT_ARROW = '→'
const RIGHT_ARROW_PREFIX = '-'
const NO_LIGATURE_CHANGE: ArrowLigatureResolution = {
change: null,
nextLiteralAsciiCursor: null,
}
const INPUT_RULES: Record<string, readonly ArrowLigatureRule[]> = {
'-': [
{
insert: LEFT_RIGHT_ARROW_PREFIX,
nextLiteralAsciiCursor: 0,
prefix: ESCAPED_LEFT_ARROW_PREFIX,
},
{
insert: LEFT_ARROW,
nextLiteralAsciiCursor: null,
prefix: LEFT_ARROW_PREFIX,
},
],
'>': [
{
insert: `${RIGHT_ARROW_PREFIX}>`,
nextLiteralAsciiCursor: null,
prefix: ESCAPED_RIGHT_ARROW_PREFIX,
},
{
insert: LEFT_RIGHT_ARROW,
nextLiteralAsciiCursor: null,
prefix: LEFT_ARROW,
},
{
insert: LEFT_RIGHT_ARROW,
nextLiteralAsciiCursor: null,
prefix: LEFT_RIGHT_ARROW_PREFIX,
},
{
insert: RIGHT_ARROW,
nextLiteralAsciiCursor: null,
prefix: RIGHT_ARROW_PREFIX,
},
],
}
function buildChange(cursor: number, charsBeforeCursor: number, insert: string): ArrowLigatureChange {
return {
from: cursor - charsBeforeCursor,
insert,
to: cursor,
}
}
function resolveMatchingRule(
beforeText: string,
inputText: string,
) {
const rules = INPUT_RULES[inputText]
return rules?.find((rule) => beforeText.endsWith(rule.prefix)) ?? null
}
function shouldKeepLiteralAsciiSequence(params: {
beforeText: string
cursor: number
inputText: string
literalAsciiCursor: number | null
}) {
const { beforeText, cursor, inputText, literalAsciiCursor } = params
return inputText === '>'
&& literalAsciiCursor === cursor
&& beforeText.endsWith(LEFT_RIGHT_ARROW_PREFIX)
}
function resolveNextLiteralAsciiCursor(
cursor: number,
rule: ArrowLigatureRule,
) {
return rule.nextLiteralAsciiCursor === 0 ? cursor : null
}
function buildResolution(
cursor: number,
rule: ArrowLigatureRule | null,
): ArrowLigatureResolution {
if (!rule) return NO_LIGATURE_CHANGE
return {
change: buildChange(cursor, rule.prefix.length, rule.insert),
nextLiteralAsciiCursor: resolveNextLiteralAsciiCursor(cursor, rule),
}
}
export function resolveArrowLigatureInput({
beforeText,
cursor,
inputText,
literalAsciiCursor,
}: ResolveArrowLigatureInputParams): ArrowLigatureResolution {
if (inputText.length !== 1) return NO_LIGATURE_CHANGE
if (shouldKeepLiteralAsciiSequence({
beforeText,
cursor,
inputText,
literalAsciiCursor,
})) {
return NO_LIGATURE_CHANGE
}
return buildResolution(cursor, resolveMatchingRule(beforeText, inputText))
}

View File

@@ -11,6 +11,12 @@ vi.mock('../lib/appUpdater', () => ({
isRestartRequiredAfterUpdate: vi.fn(() => false),
}))
const openMock = vi.fn()
vi.mock('@tauri-apps/plugin-dialog', () => ({
open: (...args: unknown[]) => openMock(...args),
}))
import { pickFolder } from './vault-dialog'
import { isTauri } from '../mock-tauri'
import {
@@ -48,10 +54,44 @@ describe('pickFolder', () => {
expect(window.prompt).toHaveBeenCalledWith('Enter folder path:')
})
it('normalizes file URLs returned by the browser fallback prompt', async () => {
vi.mocked(isTauri).mockReturnValue(false)
vi.spyOn(window, 'prompt').mockReturnValue('file:///Users/test/My%20Vault')
const result = await pickFolder('Select vault')
expect(result).toBe('/Users/test/My Vault')
})
it('blocks the native folder picker when a restart is required after update install', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(true)
await expect(pickFolder('Select vault')).rejects.toThrow(RESTART_REQUIRED_FOLDER_PICKER_MESSAGE)
})
it('normalizes a native single-selection array to its first folder path', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
openMock.mockResolvedValue(['/Users/test/my-vault'])
const result = await pickFolder('Select vault')
expect(result).toBe('/Users/test/my-vault')
expect(openMock).toHaveBeenCalledWith({
directory: true,
multiple: false,
title: 'Select vault',
})
})
it('normalizes native file URLs to filesystem paths', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(isRestartRequiredAfterUpdate).mockReturnValue(false)
openMock.mockResolvedValue('file:///Users/test/My%20Vault')
const result = await pickFolder('Select vault')
expect(result).toBe('/Users/test/My Vault')
})
})

View File

@@ -41,6 +41,40 @@ export function formatFolderPickerActionError(
return message ? `${action}: ${message}` : action
}
function normalizePickedFolderPath(selected: string | string[] | null): string | null {
const selectedPath = Array.isArray(selected)
? (typeof selected[0] === 'string' ? selected[0] : null)
: selected
if (typeof selectedPath !== 'string') {
return null
}
if (!selectedPath.startsWith('file://')) {
return selectedPath
}
try {
const parsed = new URL(selectedPath)
if (parsed.protocol !== 'file:') {
return selectedPath
}
const decodedPath = decodeURIComponent(parsed.pathname)
if (parsed.hostname) {
return `//${parsed.hostname}${decodedPath}`
}
if (/^\/[A-Za-z]:/.test(decodedPath)) {
return decodedPath.slice(1)
}
return decodedPath
} catch {
return selectedPath
}
}
/**
* Opens a native folder picker dialog (Tauri) or falls back to prompt (browser).
* Returns the selected folder path, or null if the user cancelled.
@@ -57,8 +91,8 @@ export async function pickFolder(title?: string): Promise<string | null> {
multiple: false,
title: title ?? 'Select folder',
})
return selected as string | null
return normalizePickedFolderPath(selected)
}
// Browser fallback: prompt for path
return prompt(title ?? 'Enter folder path:')
return normalizePickedFolderPath(prompt(title ?? 'Enter folder path:'))
}

View File

@@ -12,11 +12,11 @@ vi.mock('../mock-tauri', () => ({
}))
function assetUrl(path: string): string {
return `http://asset.localhost/${encodeURIComponent(path)}`
return `asset://localhost/${encodeURIComponent(path)}`
}
function legacyAssetUrl(path: string): string {
return `asset://localhost/${encodeURIComponent(path)}`
function httpAssetUrl(path: string): string {
return `http://asset.localhost/${encodeURIComponent(path)}`
}
describe('resolveImageUrls', () => {
@@ -53,7 +53,7 @@ describe('resolveImageUrls', () => {
it('rewrites legacy asset URLs from a different vault', () => {
tauriMode = true
const legacyUrl = legacyAssetUrl('/Users/luca/Workspace/tolaria-getting-started/attachments/CleanShot.png')
const legacyUrl = assetUrl('/Users/luca/Workspace/tolaria-getting-started/attachments/CleanShot.png')
const markdown = `![CleanShot](${legacyUrl})`
expect(resolveImageUrls(markdown, '/Users/john/Documents/Getting Started')).toBe(
@@ -61,6 +61,14 @@ describe('resolveImageUrls', () => {
)
})
it('leaves already-correct http asset URLs unchanged', () => {
tauriMode = true
const url = httpAssetUrl('/vault/attachments/file.png')
const markdown = `![alt](${url})`
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
})
it('leaves external URLs unchanged', () => {
tauriMode = true
const httpImage = '![logo](https://example.com/logo.png)'
@@ -72,7 +80,7 @@ describe('resolveImageUrls', () => {
it('handles multiple images in one document', () => {
tauriMode = true
const markdown = `![a](${legacyAssetUrl('/old/attachments/a.png')})\n\n![b](attachments/b.png)`
const markdown = `![a](${assetUrl('/old/attachments/a.png')})\n\n![b](attachments/b.png)`
const result = resolveImageUrls(markdown, '/vault')
@@ -91,7 +99,7 @@ describe('resolveImageUrls', () => {
it('skips unknown asset URLs without an attachments segment', () => {
tauriMode = true
const url = assetUrl('/some/other/path/file.png')
const url = httpAssetUrl('/some/other/path/file.png')
const markdown = `![alt](${url})`
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
@@ -109,7 +117,7 @@ describe('portableImageUrls', () => {
})
it('converts legacy asset protocol attachment URLs to relative paths', () => {
const url = legacyAssetUrl('/vault/attachments/legacy.png')
const url = httpAssetUrl('/vault/attachments/legacy.png')
const markdown = `![screenshot](${url})`
expect(portableImageUrls(markdown, '/vault')).toBe(

View File

@@ -17,7 +17,7 @@ type MarkdownImageUrl = string
const MD_IMAGE_PATTERN = /!\[([^\]]*)\]\(([^)\s"]+)(\s+"[^"]*")?\)/g
function assetUrl(path: AbsolutePath): MarkdownImageUrl {
return normalizeWebviewAssetUrl(convertFileSrc(path), path)
return convertFileSrc(path)
}
function vaultAttachmentPath(vaultPath: VaultPath, attachmentPath: AttachmentPath): AbsolutePath {
@@ -32,13 +32,6 @@ function extractAttachmentPath(absolutePath: AbsolutePath): AttachmentPath | nul
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
}
function normalizeWebviewAssetUrl(url: MarkdownImageUrl, path: AbsolutePath): MarkdownImageUrl {
if (url.startsWith(ASSET_URL_PREFIX)) {
return `${HTTP_ASSET_URL_PREFIX}${encodeURIComponent(path)}`
}
return url
}
function assetUrlPrefix(url: MarkdownImageUrl): string | null {
return ASSET_URL_PREFIXES.find(prefix => url.startsWith(prefix)) ?? null
}

View File

@@ -297,10 +297,22 @@ async function installFixtureVaultInitScript({ page, vaultPath }: FixtureVaultPa
content: readCommandValue(commandArgs, 'content'),
}),
}),
create_note_content: async (commandArgs?: FixtureCommandArgs) => {
const notePath = readCommandString(commandArgs, 'path')
const existing = await nativeFetch(`/api/vault/content?path=${encodeURIComponent(notePath)}`)
if (existing.ok) throw new Error(`File already exists: ${notePath}`)
return readJson('/api/vault/save', {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({
path: notePath,
content: readCommandValue(commandArgs, 'content'),
}),
})
},
update_frontmatter: (commandArgs?: FixtureCommandArgs) =>
persistFrontmatterChange(
readCommandString(commandArgs, 'path'),
(content) => replaceFrontmatterEntry(
persistFrontmatterChange(readCommandString(commandArgs, 'path'), (content) =>
replaceFrontmatterEntry(
content,
readCommandString(commandArgs, 'key'),
readCommandValue(commandArgs, 'value'),

View File

@@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { sendShortcut } from './helpers'
import { openCommandPalette, sendShortcut } from './helpers'
let tempVaultDir: string
@@ -16,6 +16,20 @@ function toast(page: import('@playwright/test').Page) {
return page.locator('.fixed.bottom-8')
}
async function createTypeFromCommandPalette(page: import('@playwright/test').Page, typeName: string): Promise<void> {
await openCommandPalette(page)
await page.locator('input[placeholder="Type a command..."]').fill('new type')
await page.keyboard.press('Enter')
const dialog = page.getByRole('dialog', { name: 'Create New Type' })
const title = dialog.getByText('Create New Type', { exact: true })
const typeInput = dialog.getByPlaceholder('e.g. Recipe, Book, Habit...')
await expect(title).toBeVisible()
await typeInput.fill(typeName)
await dialog.getByRole('button', { name: 'Create' }).click()
await expect(dialog).toHaveCount(0)
}
test.describe('Collision-safe create flows', () => {
test.beforeEach(async ({ page }) => {
tempVaultDir = createFixtureVaultCopy()
@@ -82,4 +96,20 @@ test.describe('Collision-safe create flows', () => {
await expect(toast(page)).toContainText('Cannot create note "Briefing!" because briefing.md already exists')
expect(fs.readFileSync(collidingPath, 'utf8')).toContain('# Weekly Sync')
})
test('unicode type creation ignores an unrelated untitled draft filename', async ({ page }) => {
const untitledPath = writeFixtureNote(
tempVaultDir,
'untitled.md',
'---\ntype: Note\n---\n# Existing Untitled Note\n',
)
const createdTypePath = path.join(tempVaultDir, '停智慧.md')
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await createTypeFromCommandPalette(page, '停智慧')
await expect.poll(() => fs.existsSync(createdTypePath)).toBe(true)
expect(fs.readFileSync(createdTypePath, 'utf8')).toContain('type: Type')
expect(fs.readFileSync(untitledPath, 'utf8')).toContain('# Existing Untitled Note')
})
})

View File

@@ -1,6 +1,7 @@
import path from 'node:path'
import { test, expect, type Page } from '@playwright/test'
const REMEMBERED_DEFAULT_VAULT_PATH = '/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2'
const DEFAULT_VAULT_PATH = path.resolve(process.cwd(), 'demo-vault-v2')
async function mockFreshStart(
page: Page,
@@ -11,36 +12,51 @@ async function mockFreshStart(
},
) {
await page.addInitScript((config) => {
type Handler = (args?: Record<string, unknown>) => unknown
type BrowserWindow = Window & typeof globalThis & {
__mockHandlers?: Record<string, Handler>
}
const browserWindow = window as BrowserWindow
localStorage.clear()
localStorage.setItem('tolaria:ai-agents-onboarding-dismissed', '1')
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
if (config.rememberWelcomeDismissal) {
localStorage.setItem('tolaria_welcome_dismissed', '1')
}
let ref: Record<string, unknown> | null = null
const applyOverrides = (handlers?: Record<string, Handler> | null) => {
if (!handlers) return handlers ?? null
Object.defineProperty(window, '__mockHandlers', {
const originalGetSettings = handlers.get_settings
handlers.get_settings = () => ({
...(typeof originalGetSettings === 'function' ? originalGetSettings() as Record<string, unknown> : {}),
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
})
handlers.load_vault_list = () => ({
vaults: [],
active_vault: config.activeVault,
hidden_defaults: [],
})
handlers.get_default_vault_path = () => config.checkExistingPath
handlers.check_vault_exists = (args?: Record<string, unknown>) => args?.path === config.checkExistingPath
return handlers
}
let ref = applyOverrides(browserWindow.__mockHandlers) ?? null
Object.defineProperty(browserWindow, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
const originalGetSettings = ref.get_settings as (() => Record<string, unknown>) | undefined
ref.get_settings = () => ({
...(originalGetSettings ? originalGetSettings() : {}),
telemetry_consent: null,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
})
ref.load_vault_list = () => ({
vaults: [],
active_vault: config.activeVault,
hidden_defaults: [],
})
ref.get_default_vault_path = () => config.checkExistingPath
ref.check_vault_exists = (args: { path?: string }) => args?.path === config.checkExistingPath
ref = applyOverrides(value as Record<string, Handler> | undefined) ?? null
},
get() {
return ref
return applyOverrides(ref) ?? ref
},
})
}, options)
@@ -49,7 +65,7 @@ async function mockFreshStart(
test('accepting telemetry consent on a fresh start opens the vault choice wizard @smoke', async ({ page }) => {
await mockFreshStart(page, {
activeVault: null,
checkExistingPath: '/Users/mock/Documents/Getting Started',
checkExistingPath: DEFAULT_VAULT_PATH,
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
@@ -65,7 +81,7 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
test('telemetry consent still leaves the welcome wizard fully keyboard navigable @smoke', async ({ page }) => {
await mockFreshStart(page, {
activeVault: null,
checkExistingPath: '/Users/mock/Documents/Getting Started',
checkExistingPath: DEFAULT_VAULT_PATH,
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
@@ -104,8 +120,8 @@ test('telemetry consent still leaves the welcome wizard fully keyboard navigable
for (const action of ['accept', 'decline'] as const) {
test(`${action} telemetry still resumes onboarding with only a remembered default vault @smoke`, async ({ page }) => {
await mockFreshStart(page, {
activeVault: REMEMBERED_DEFAULT_VAULT_PATH,
checkExistingPath: REMEMBERED_DEFAULT_VAULT_PATH,
activeVault: DEFAULT_VAULT_PATH,
checkExistingPath: DEFAULT_VAULT_PATH,
rememberWelcomeDismissal: true,
})

View File

@@ -0,0 +1,205 @@
import { test, expect, type Page } from '@playwright/test'
interface MockConfig {
availablePaths: string[]
promptPath: string
}
interface PersistedState {
active_vault: string | null
availablePaths: string[]
hidden_defaults: string[]
vaults: Array<{ label: string; path: string }>
}
const GETTING_STARTED_PATH = '/Users/mock/Documents/Getting Started'
const STATE_KEY = 'tolaria:test-vault-state'
async function installOnboardingVaultMocks(page: Page, config: MockConfig) {
await page.addInitScript((mockConfig) => {
const stateKey = 'tolaria:test-vault-state'
const defaultSettings = {
auto_pull_interval_minutes: null,
telemetry_consent: true,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
}
const buildEntries = (vaultPath: string) => {
if (!vaultPath) {
return []
}
return [{
path: `${vaultPath}/welcome.md`,
filename: 'welcome.md',
title: 'Welcome',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: 'Welcome note',
wordCount: 3,
relationships: {},
properties: {},
template: null,
sort: null,
outgoingLinks: [],
}]
}
const defaultState = {
vaults: [],
active_vault: null,
hidden_defaults: [],
availablePaths: [...mockConfig.availablePaths],
}
const readState = (): PersistedState => {
const raw = localStorage.getItem(stateKey)
return raw ? JSON.parse(raw) as PersistedState : defaultState
}
const writeState = (state: PersistedState) => {
localStorage.setItem(stateKey, JSON.stringify(state))
}
localStorage.setItem('tolaria:ai-agents-onboarding-dismissed', '1')
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
if (!localStorage.getItem(stateKey)) {
writeState(defaultState)
}
Object.defineProperty(window, 'prompt', {
configurable: true,
value: () => mockConfig.promptPath,
})
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
ref.load_vault_list = () => {
const state = readState()
return {
vaults: [...state.vaults],
active_vault: state.active_vault,
hidden_defaults: [...state.hidden_defaults],
}
}
ref.save_vault_list = (args: { list: PersistedState }) => {
const state = readState()
writeState({
...state,
vaults: [...args.list.vaults],
active_vault: args.list.active_vault,
hidden_defaults: [...(args.list.hidden_defaults ?? [])],
})
return null
}
ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started'
ref.check_vault_exists = (args: { path?: string | null }) => {
const state = readState()
return !!args.path && state.availablePaths.includes(args.path)
}
ref.create_getting_started_vault = (args: { targetPath?: string | null }) => {
if (!args.targetPath) {
throw new Error('Target path is required')
}
const state = readState()
writeState({
...state,
availablePaths: [...new Set([...state.availablePaths, args.targetPath])],
})
return args.targetPath
}
ref.list_vault = (args: { path?: string | null }) => buildEntries(args.path ?? readState().active_vault ?? '')
ref.list_vault_folders = () => []
ref.list_views = () => []
ref.get_all_content = () => {
const activeVault = readState().active_vault ?? ''
return Object.fromEntries(
buildEntries(activeVault).map((entry) => [entry.path, '# Welcome\n\nWelcome note']),
)
}
ref.get_note_content = () => '# Welcome\n\nWelcome note'
ref.get_modified_files = () => []
ref.get_file_history = () => []
ref.get_settings = () => defaultSettings
ref.save_settings = () => null
},
get() {
return ref
},
})
}, config)
}
async function readPersistedState(page: Page): Promise<PersistedState> {
return page.evaluate((stateKey) => (
JSON.parse(localStorage.getItem(stateKey) ?? 'null') as PersistedState
), STATE_KEY)
}
test('opening an existing vault from onboarding persists the selection and survives reload @smoke', async ({ page }) => {
const vaultPath = '/Users/mock/Work'
const vaultUrl = 'file:///Users/mock/Work'
await installOnboardingVaultMocks(page, {
availablePaths: [vaultPath],
promptPath: vaultUrl,
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await page.getByTestId('welcome-open-folder').click()
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('status-vault-trigger')).toContainText('Work')
await expect.poll(() => readPersistedState(page)).toEqual({
vaults: [{ label: 'Work', path: vaultPath }],
active_vault: vaultPath,
hidden_defaults: [],
availablePaths: [vaultPath],
})
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('welcome-screen')).not.toBeVisible()
await expect(page.getByTestId('status-vault-trigger')).toContainText('Work')
await expect(page.getByTestId('note-list-container')).toBeVisible()
})
test('cloning Getting Started from onboarding persists the default vault and survives reload @smoke', async ({ page }) => {
await installOnboardingVaultMocks(page, {
availablePaths: [],
promptPath: 'file:///Users/mock/Documents',
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await page.getByTestId('welcome-create-vault').click()
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('status-vault-trigger')).toContainText('Getting Started')
await expect.poll(async () => (await readPersistedState(page)).active_vault).toBe(GETTING_STARTED_PATH)
await expect.poll(async () => (await readPersistedState(page)).availablePaths).toEqual([GETTING_STARTED_PATH])
await page.reload({ waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('welcome-screen')).not.toBeVisible()
await expect(page.getByTestId('status-vault-trigger')).toContainText('Getting Started')
await expect(page.getByTestId('note-list-container')).toBeVisible()
})

View File

@@ -0,0 +1,59 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
let tempVaultDir: string
function trackUnexpectedErrors(page: Page): string[] {
const errors: string[] = []
page.on('pageerror', (error) => {
errors.push(error.message)
})
page.on('console', (message) => {
if (message.type() !== 'error') return
const text = message.text()
if (text.includes('ws://localhost:9711')) return
errors.push(text)
})
return errors
}
async function createUntitledNote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
test.describe('table focus regression', () => {
test.beforeEach(({ page }, testInfo) => {
void page
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('table headers stay editable after moving focus back to paragraph content', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
const editor = page.getByRole('textbox').last()
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await seedBlockNoteTable(page, [180, 120, 120])
await expect(page.locator('table th')).toHaveCount(3, { timeout: 5_000 })
await page.locator('table th').first().click()
const trailingParagraph = page.locator('.bn-editor [data-content-type="paragraph"]').last()
await trailingParagraph.click()
await page.keyboard.type('typed after table focus')
await expect(editor).toContainText('typed after table focus')
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
})