Compare commits
24 Commits
alpha-v202
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba5c165016 | ||
|
|
2308b269c8 | ||
|
|
b7f482bf27 | ||
|
|
33d7404d6c | ||
|
|
0ecac2ab79 | ||
|
|
7c9f2b0dda | ||
|
|
24e317cfa6 | ||
|
|
66c183fa61 | ||
|
|
dacf9bb19b | ||
|
|
3c92c200ab | ||
|
|
61c3a3e21b | ||
|
|
2e7c94d159 | ||
|
|
98ade7411a | ||
|
|
18776341f8 | ||
|
|
57bbcdbaef | ||
|
|
42bb6c4b18 | ||
|
|
070cd3ac6d | ||
|
|
accc6270d3 | ||
|
|
941f12aa1b | ||
|
|
4710a2d0a9 | ||
|
|
65a421215a | ||
|
|
4ed0289989 | ||
|
|
4ad32faa7b | ||
|
|
c9623d5ca8 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.94
|
||||
AVERAGE_THRESHOLD=9.81
|
||||
AVERAGE_THRESHOLD=9.83
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
37
docs/adr/0077-concurrent-safe-vault-cache-replacement.md
Normal file
37
docs/adr/0077-concurrent-safe-vault-cache-replacement.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
16
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¤t_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
108
src/App.test.tsx
108
src/App.test.tsx
@@ -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(() => {
|
||||
|
||||
47
src/App.tsx
47
src/App.tsx
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => []),
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
132
src/components/arrowLigaturesExtension.test.ts
Normal file
132
src/components/arrowLigaturesExtension.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
68
src/components/arrowLigaturesExtension.ts
Normal file
68
src/components/arrowLigaturesExtension.ts
Normal 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
|
||||
})
|
||||
@@ -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
|
||||
|
||||
56
src/components/editorRawModeSync.arrowLigatures.test.ts
Normal file
56
src/components/editorRawModeSync.arrowLigatures.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
93
src/hooks/useCodeMirror.arrowLigatures.test.ts
Normal file
93
src/hooks/useCodeMirror.arrowLigatures.test.ts
Normal 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('→')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
63
src/lib/blockNoteTableHandles.regression.test.ts
Normal file
63
src/lib/blockNoteTableHandles.regression.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
126
src/utils/arrowLigatures.test.ts
Normal file
126
src/utils/arrowLigatures.test.ts
Normal 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
140
src/utils/arrowLigatures.ts
Normal 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))
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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:'))
|
||||
}
|
||||
|
||||
@@ -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 = ``
|
||||
|
||||
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 = ``
|
||||
|
||||
expect(resolveImageUrls(markdown, '/vault')).toBe(markdown)
|
||||
})
|
||||
|
||||
it('leaves external URLs unchanged', () => {
|
||||
tauriMode = true
|
||||
const httpImage = ''
|
||||
@@ -72,7 +80,7 @@ describe('resolveImageUrls', () => {
|
||||
|
||||
it('handles multiple images in one document', () => {
|
||||
tauriMode = true
|
||||
const markdown = `})\n\n`
|
||||
const markdown = `})\n\n`
|
||||
|
||||
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 = ``
|
||||
|
||||
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 = ``
|
||||
|
||||
expect(portableImageUrls(markdown, '/vault')).toBe(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
205
tests/smoke/onboarding-vault-registration.spec.ts
Normal file
205
tests/smoke/onboarding-vault-registration.spec.ts
Normal 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()
|
||||
})
|
||||
59
tests/smoke/table-focus-regression.spec.ts
Normal file
59
tests/smoke/table-focus-regression.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user