Compare commits

..

50 Commits

Author SHA1 Message Date
lucaronin
384c752c33 docs: add release notes for v2026-05-14 2026-05-14 18:12:17 +02:00
lucaronin
e844cfa0bc feat: support vault reordering 2026-05-14 17:30:13 +02:00
lucaronin
7174061b2a fix: warm parsed blocks for large notes 2026-05-14 17:20:49 +02:00
lucaronin
cf03c01b75 fix: use stock appimage packaging in release 2026-05-14 17:04:36 +02:00
lucaronin
8b83b2c691 fix: preserve appimage plugin name 2026-05-14 16:44:46 +02:00
lucaronin
d445c72edb fix: extract appimage tools in linux release 2026-05-14 16:33:56 +02:00
lucaronin
9a8aacad40 fix: install fuse for linux appimage builds 2026-05-14 16:27:21 +02:00
lucaronin
416131aa8f fix: restore TypeScript build 2026-05-14 16:09:54 +02:00
lucaronin
8b1d2bbed8 fix: support relationship equality filters 2026-05-14 16:01:34 +02:00
github-actions[bot]
9a7a31c47b Merge branch 'main' into fix/view_filters 2026-05-14 13:27:51 +00:00
lucaronin
62e62790c1 feat: stabilize external mcp appimage registration
Co-authored-by: Domenico Lupinetti <domenico@translated.net>
2026-05-14 15:22:47 +02:00
lucaronin
3b2f675264 fix: create notes in selected vault 2026-05-14 15:12:56 +02:00
github-actions[bot]
9706ee8ec2 Merge branch 'main' into fix/view_filters 2026-05-14 11:57:37 +00:00
lucaronin
c9a4b273d6 refactor: simplify file attachment serialization 2026-05-14 13:43:14 +02:00
github-actions[bot]
8fe02f8c26 Merge branch 'main' into fix/view_filters 2026-05-14 11:38:23 +00:00
lucaronin
18f91af77f fix: preserve file attachment raw toggles 2026-05-14 13:29:33 +02:00
github-actions[bot]
1c047079f0 Merge branch 'main' into fix/view_filters 2026-05-14 11:16:48 +00:00
lucaronin
a8273ad7a9 feat: make external mcp vault resolution dynamic
Port the useful dynamic-vault MCP direction from Domenico Lupinetti's proposal into the current mounted-workspace model.

Durable external MCP registration is now vault-neutral, Node MCP resolves active mounted vaults from explicit env or Tolaria vaults.json, and vault context includes root AGENTS.md instructions per active vault.

Based-on: https://github.com/refactoringhq/tolaria/pull/603

Co-authored-by: Domenico Lupinetti <domenico@translated.net>
2026-05-14 13:06:43 +02:00
github-actions[bot]
133b2601a2 Merge branch 'main' into fix/view_filters 2026-05-14 11:06:39 +00:00
lucaronin
21d4577079 fix: apply webkit safeguards on linux wayland 2026-05-14 12:55:09 +02:00
github-actions[bot]
c19003cf39 Merge branch 'main' into fix/view_filters 2026-05-14 10:42:50 +00:00
lucaronin
d99da92b43 fix: preserve windows fullscreen navigation 2026-05-14 12:32:42 +02:00
github-actions[bot]
8b9c4592f1 Merge branch 'main' into fix/view_filters 2026-05-14 10:23:26 +00:00
lucaronin
832feb7df1 fix: standardize icons on phosphor 2026-05-14 12:14:16 +02:00
github-actions[bot]
506e8d0eac Merge branch 'main' into fix/view_filters 2026-05-14 10:11:02 +00:00
lucaronin
7cd346bde0 fix: prevent duplicate vault folder pickers 2026-05-14 11:59:20 +02:00
Andrius Miasnikovas
47f5da1798 Fix equals and not equals filters not working in views 2026-05-14 11:08:48 +03:00
lucaronin
ff4939fb29 fix: recover procedure editor schema errors 2026-05-14 09:55:20 +02:00
lucaronin
3fa1d34c32 fix: stabilize table handle controls after reload 2026-05-14 09:22:44 +02:00
lucaronin
2ef4d95bb5 fix: retarget externally moved inbox notes 2026-05-14 08:15:49 +02:00
lucaronin
ce715ed5aa docs: add/update ADR for entry-scoped note windows (guard — from commit 6bb9c7f) 2026-05-14 08:05:28 +02:00
lucaronin
95dab219b8 fix: preserve cjk code block copy text 2026-05-14 07:48:04 +02:00
lucaronin
2fcea0624d test: stabilize type-derived smoke setup 2026-05-14 06:57:25 +02:00
lucaronin
7f8aad0686 fix: clean up invalid mermaid renders 2026-05-14 06:43:50 +02:00
lucaronin
2eae7a0899 fix: preserve focused editor after pulls 2026-05-14 05:53:37 +02:00
lucaronin
6bb9c7fbc9 fix: avoid note-window vault scans 2026-05-14 05:05:33 +02:00
lucaronin
9d9681345d ci: keep linux check off push lane 2026-05-14 04:05:05 +02:00
lucaronin
201aefca05 fix: bundle fcitx gtk module in appimage 2026-05-13 16:44:38 +02:00
lucaronin
41f0e00cfb fix: support symlinked appimage launches 2026-05-13 16:06:57 +02:00
lucaronin
9be428aad3 fix: sanitize appimage mcp node launches 2026-05-13 15:21:36 +02:00
lucaronin
b2d23a8559 fix: dedupe calendar stable releases 2026-05-13 14:47:26 +02:00
lucaronin
d6ad2ff836 test: isolate pi agent config tests 2026-05-13 14:32:04 +02:00
lucaronin
6ac8f822ff test: stabilize autogit smoke setup 2026-05-13 14:18:03 +02:00
lucaronin
b0c8eff6c7 fix: handle autogit author failures 2026-05-13 14:12:23 +02:00
lucaronin
ae7a468310 test: guard reload editing loop 2026-05-13 13:24:41 +02:00
lucaronin
84a993b948 test: guard fresh-note enter selection 2026-05-13 13:03:36 +02:00
lucaronin
2a5ebcf73d fix: prevent whiteboard dialog crash 2026-05-13 12:42:37 +02:00
lucaronin
09978cf8e5 fix: preserve ampersand in native commit menu 2026-05-13 11:52:06 +02:00
lucaronin
0e981228e2 fix: point release history link to public site 2026-05-13 11:41:36 +02:00
lucaronin
b77ebd2a02 docs: add release notes for v2026-05-12 2026-05-13 11:33:02 +02:00
145 changed files with 5068 additions and 1250 deletions

View File

@@ -195,6 +195,11 @@ jobs:
linux-build:
name: Linux build verification
# Keep the normal push CI lane under the 10-minute target. The release
# workflows already perform the full Linux/AppImage build after main
# pushes, so this slower compatibility check stays available for PRs and
# manual diagnostics without blocking every direct push.
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-22.04
steps:
@@ -209,6 +214,7 @@ jobs:
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
libfuse2 \
librsvg2-dev \
patchelf \
build-essential \

View File

@@ -283,6 +283,8 @@ jobs:
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
@@ -333,6 +335,7 @@ jobs:
- name: Build Tauri app (Linux bundles)
env:
APPIMAGE_EXTRACT_AND_RUN: 1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
@@ -346,8 +349,11 @@ jobs:
- name: Validate Linux bundles
run: |
shopt -s nullglob
installers=(
appimages=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
)
installers=(
"${appimages[@]}"
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
)
@@ -356,6 +362,10 @@ jobs:
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#appimages[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage bundle."
exit 1
fi
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
exit 1

View File

@@ -339,6 +339,8 @@ jobs:
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
@@ -389,6 +391,7 @@ jobs:
- name: Build Tauri app (Linux bundles)
env:
APPIMAGE_EXTRACT_AND_RUN: 1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
@@ -402,8 +405,11 @@ jobs:
- name: Validate Linux bundles
run: |
shopt -s nullglob
installers=(
appimages=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
)
installers=(
"${appimages[@]}"
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
)
@@ -412,6 +418,10 @@ jobs:
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#appimages[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage bundle."
exit 1
fi
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
exit 1

View File

@@ -17,5 +17,5 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"iconLibrary": "phosphor"
}

View File

@@ -411,7 +411,7 @@ Renderer attachment paths are normalized through `src/utils/vaultAttachments.ts`
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet remains scoped to the active vault without writing third-party config files. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
### Vault Caching
@@ -632,7 +632,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
```mermaid
flowchart LR
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
B --> C["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences → tokens"]
B --> C["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences + file links → tokens"]
C --> D["preProcessWikilinks(body)\n[[target]] → token"]
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
@@ -643,7 +643,7 @@ flowchart LR
style H fill:#d4edda,stroke:#28a745,color:#000
```
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, and tldraw placeholder tokens use ASCII sentinels with URI-encoded payloads.
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, tldraw, and standalone file-attachment link placeholders use ASCII sentinels with URI-encoded payloads.
### BlockNote-to-Markdown Pipeline (Save)
@@ -658,7 +658,7 @@ flowchart LR
style E fill:#d4edda,stroke:#28a745,color:#000
```
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. `src/utils/richEditorMarkdown.ts` is the shared BlockNote-to-Markdown owner for autosave/tab-swap and raw-mode entry, so wikilink restoration, durable schema-node serialization, frontmatter preservation, and portable attachment paths cannot drift between editor modes. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. `src/utils/richEditorMarkdown.ts` is the shared BlockNote-to-Markdown owner for autosave/tab-swap and raw-mode entry, so wikilink restoration, durable schema-node serialization, frontmatter preservation, file-attachment block round-tripping, and portable attachment paths cannot drift between editor modes. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
Autosave then waits for a 1.5s idle window before invoking `save_note_content`. If an older save resolves after the user has already typed newer content, the older save is treated as stale and cannot clear the newer pending buffer or repaint tab state over it; the latest pending content remains scheduled for its own save.
@@ -820,8 +820,9 @@ Tolaria delegates remote auth to the user's system git setup:
- `CloneVaultModal` captures a remote URL and local destination
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
- On macOS, system-git commands prefer the user's login-shell `git` and `PATH`, and `git_add_remote` preflights HTTPS remotes through `git credential fill` so Keychain can prompt/grant access before the first fetch or push
- On Linux AppImage launches, every system-git command removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning `git`, so helpers like `git-remote-https` bind against the host git/library stack instead of Tolaria's bundled WebKit/AppImage libraries
- On Linux AppImage Wayland launches, startup environment safeguards also set `GTK_IM_MODULE=fcitx` when common input-method variables already indicate fcitx and the user has not explicitly chosen a GTK IM module, allowing WebKitGTK editor input to reach fcitx5 on compositors such as niri without overriding deliberate `GTK_IM_MODULE` choices.
- On Linux AppImage launches, every system-git command and MCP Node subprocess removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning, so helpers like `git-remote-https` and system `node` bind against the host library stack instead of Tolaria's bundled WebKit/AppImage libraries
- On native Linux Wayland launches and Linux AppImage launches, startup environment safeguards set `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` unless the user already provided either variable, keeping WebKitGTK rendering crashes out of the app startup path while leaving native X11 launches unchanged.
- On Linux AppImage launches, release packaging bundles the GTK3 fcitx immodule into the AppImage and startup environment safeguards write a cache-local `GTK_IM_MODULE_FILE` that points GTK at the mounted module whenever fcitx is configured. If the user has not explicitly chosen a GTK IM module, Tolaria also sets `GTK_IM_MODULE=fcitx`, allowing WebKitGTK editor input to reach fcitx5 on both Wayland and X11 fallback launches without relying on host GTK module cache paths.
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors, and clone commands fail fast when git wants interactive terminal input
- No provider-specific token or username is stored in app settings
@@ -905,6 +906,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, Linux x86_64 `.deb` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -135,7 +135,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
| Raw editor | CodeMirror 6 | - |
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
| UI primitives | Radix UI + shadcn/ui | - |
| Icons | Phosphor Icons + Lucide | - |
| Icons | Phosphor Icons | - |
| Build | Vite | 7.3.1 |
| Backend language | Rust (edition 2021) | 1.77.2 |
| Frontmatter parsing | gray_matter | 0.2 |
@@ -227,14 +227,14 @@ flowchart TD
Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command can grow the current window back out when a wider pane combination is restored, but Windows keeps grow-to-fit disabled so opening or closing the Properties panel does not unmaximize or reposition the main window. Note windows skip this path and keep their dedicated 800×700 initial sizing.
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command can grow the current window back out when a wider pane combination is restored, but Windows keeps grow-to-fit disabled and skips min-size mutation while fullscreen or maximized so navigation/sidebar interactions do not unfullscreen or reposition the main window. Note windows skip this path and keep their dedicated 800×700 initial sizing.
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers the macOS Window submenu with Tauri's reserved `WINDOW_SUBMENU_ID` so NSApp can add system window-management and window-list items, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback.
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. If an AppImage Wayland session advertises fcitx through common input-method environment hints and the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts so GTK/WebKit input contexts reach fcitx5 on compositors that do not reliably provide the Wayland input method frontend. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, the fcitx GTK fallback preserves Chinese/Japanese/Korean IME composition for affected AppImage Wayland users, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the webview. Native Wayland launches and AppImage launches inject `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, covering compositor-specific WebKit crashes without changing native X11 launches. AppImage launches keep the additional AppImage-only safeguards: on Wayland sessions Tolaria re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. Runtime startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache when fcitx is configured via `GTK_IM_MODULE=fcitx` or common fcitx environment hints; release packaging currently uses Tauri's stock linuxdeploy AppImage output plugin instead of Tolaria's experimental output-plugin shim. If the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep WebViews from blanking or crashing after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
## Multi-Window (Note Windows)
@@ -341,7 +341,7 @@ Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existi
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
### Tool Surface (14 tools)
### Tool Surface
| Tool | Params | Description |
|------|--------|-------------|
@@ -349,12 +349,13 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
| `read_note` | `path` | Read note content (alias for `open_note`) |
| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter |
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
| `list_vaults` | — | List active mounted vaults and whether each has root `AGENTS.md` instructions |
| `append_to_note` | `path, text` | Append text to end of existing note |
| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter |
| `delete_note` | `path` | Delete a note file from the vault |
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
| `vault_context` / `get_vault_context` | `[vaultPath]` | Get mounted-vault summary: entity types, folders, recent notes, and root `AGENTS.md` instructions |
| `ui_open_note` | `path` | Open a note in the Tolaria UI editor |
| `ui_open_tab` | `path` | Open a note in a new UI tab |
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
@@ -374,27 +375,28 @@ Tolaria can register itself as an MCP server in:
- `~/.gemini/settings.json` (Gemini CLI)
- `~/.cursor/mcp.json` (Cursor)
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
- `~/.config/opencode/opencode.json` (OpenCode, using its `mcp` config key)
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini/OpenCode settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes a vault-neutral `type: "stdio"` entry for standard MCP clients, writes OpenCode's vault-neutral `type: "local"` entry, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. Durable external MCP processes resolve active workspaces at tool-call time: explicit `VAULT_PATH`/`VAULT_PATHS` env still wins for app-owned and legacy launches, otherwise the Node server reads Tolaria's `vaults.json`, uses `active_vault` first, and includes every workspace not marked `mounted: false`. Vault context checks each active workspace root for `AGENTS.md` and includes those instructions in the returned context. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. Linux AppImage startup extracts the bundled `mcp-server/` to `~/.local/share/tolaria/mcp-server/` with a `.tolaria-version` marker, so durable external registrations use a stable path instead of the changing AppImage mount point. The `useMcpStatus` hook tracks whether Tolaria's durable MCP entry is connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
### Architecture
```mermaid
flowchart TD
subgraph MCP["MCP Server (Node.js) — selected-vault scoped"]
subgraph MCP["MCP Server (Node.js) — mounted-workspace scoped"]
IDX["index.js"]
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
WSB["ws-bridge.js"]
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor"]
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Gemini / OpenCode"]
IDX --> VAULT
IDX --> WSB
WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"]
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
end
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with active VAULT_PATH"| MCP
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json"]
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with VAULT_PATHS"| MCP
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json\n~/.config/opencode/opencode.json"]
```
### WebSocket Bridge
@@ -420,11 +422,12 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with `VAULT_PATH`/`VAULT_PATHS` env |
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request |
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
| `extract_mcp_server_to_stable_dir(app_version)` | On Linux AppImage launches, copies bundled MCP files to `~/.local/share/tolaria/mcp-server/` with version-gated replacement so external clients can keep a stable `index.js` path |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged or stable extracted `mcp-server/`, and writes Tolaria's vault-neutral entry to Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs on user request |
| `mcp_config_snippet(vault_path)` | Builds the exact vault-neutral `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, OpenCode, and generic MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer keeps Tauri asset protocol access limited to vault roots loaded during the current app session; command calls remain active-vault scoped for reads, writes, and external opens.
@@ -539,7 +542,7 @@ Tolaria no longer implements provider-specific OAuth or remote-repository APIs.
1. User opens `CloneVaultModal` from onboarding or the vault menu
2. User pastes any git URL and chooses a local destination
3. The `clone_git_repo()` Tauri command runs `git clone` inside a blocking Tokio task so the Tauri window stays responsive during slow or failing clones
4. Linux AppImage builds strip AppImage loader variables from system-git subprocesses before spawning `git`, keeping `git-remote-https` on the host git/library stack
4. Linux AppImage builds strip AppImage loader variables from system-git and MCP Node subprocesses before spawning them, keeping `git-remote-https` and system `node` on the host library stack
5. `git_push()` / `git_pull()` continue to use the same system git path
6. On macOS, `git_add_remote()` asks Git's credential helper for HTTPS credentials before the first fetch so Keychain can grant access to the same saved credential item the shell uses
7. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input
@@ -573,7 +576,7 @@ sequenceDiagram
participant MCP as MCP Server
participant U as User
T->>T: apply Linux AppImage WebKit env/preload safeguards<br/>(AppImage only)
T->>T: apply Linux WebKit env safeguards<br/>(Wayland/AppImage)
T->>T: start background legacy vault housekeeping<br/>(does not block setup)
T->>MCP: start background initial ws-bridge sync<br/>(if active vault exists)
T->>A: App mounts
@@ -788,9 +791,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini availability |
| `get_agent_docs_path` | Resolve the bundled local Tolaria docs folder used in AI-agent system prompts |
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, or Gemini through the normalized agent event layer |
| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
| `register_mcp_tools` | Register vault-neutral MCP in Claude/Gemini/Cursor/OpenCode/generic config |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/OpenCode/generic config |
| `check_mcp_status` | Check whether Tolaria's durable MCP entry is registered in Claude/Gemini/Cursor/OpenCode/generic config |
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
| `copy_text_to_clipboard` | Copy setup snippets through the native desktop clipboard command path |
| `read_text_from_clipboard` | Read current desktop clipboard text for command-driven plain-text paste |
@@ -922,6 +925,11 @@ push to main
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ build-linux job:
→ pnpm install, stamp version
→ tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
→ verify Linux installer and updater-signature artifacts exist
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
→ release job:
→ generate alpha-latest.json with darwin-aarch64, darwin-x86_64, Linux, and Windows updater URLs
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
@@ -944,8 +952,10 @@ push stable-vYYYY.M.D tag
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ build-linux job:
→ pnpm install, stamp version, tauri build --target x86_64-unknown-linux-gnu --bundles deb,appimage
upload .deb, .AppImage, and signed Linux updater bundles
→ pnpm install, stamp version
tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
→ verify Linux installer and updater-signature artifacts exist
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
@@ -961,6 +971,8 @@ push stable-vYYYY.M.D tag
→ deploy to gh-pages
```
Linux AppImage release jobs use Tauri's stock linuxdeploy AppImage output plugin. `scripts/appimage-launcher-tools.mjs` remains available for local experiments with symlink-safe AppRun patching and fcitx module bundling, but release packaging does not pre-seed that shim because linuxdeploy currently exits before sealing the AppImage when the shim replaces the stock output plugin in Tauri's tools cache.
### Versioning
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.

View File

@@ -37,7 +37,7 @@ On some Wayland systems, the Linux AppImage may fail to launch with:
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
```
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
Recent Tolaria Linux builds automatically disable unstable WebKitGTK rendering paths on Wayland and AppImage launches. AppImage launches also retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
```bash
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib64/libwayland-client.so.0 ./Tolaria*.AppImage
@@ -45,6 +45,16 @@ WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/u
If your distribution stores the 64-bit library elsewhere, use that path instead, for example `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`. On 64-bit Fedora, avoid `/usr/lib/libwayland-client.so.0`; that path can point at a 32-bit library and be ignored by the loader with a wrong ELF class warning.
### Linux AppImage packaging checks
Linux release CI currently uses Tauri's stock linuxdeploy AppImage output plugin:
```bash
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
```
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache.
## Quick Start
```bash
@@ -69,7 +79,7 @@ pnpm playwright:regression # Full Playwright regression suite
`create_getting_started_vault` clones the public starter repo and then removes every git remote from the new local copy. That means Getting Started vaults open local-only by default. Users connect a compatible remote later through the bottom-bar `No remote` chip or the command palette, both of which feed the same `AddRemoteModal` and `git_add_remote` backend flow.
Linux AppImage builds still use the user's system `git`. Before Tolaria spawns that `git` process, it removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` so HTTPS clone helpers use the host git libraries instead of bundled AppImage libraries.
Linux AppImage builds still use the user's system `git` and `node`. Before Tolaria spawns those Git or MCP Node subprocesses, it removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` so HTTPS clone helpers and MCP tooling use the host library stack instead of bundled AppImage libraries.
## Multiple Vaults At The Same Time
@@ -461,8 +471,9 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
### Work with external MCP setup
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs` and its `src-tauri/src/mcp/` helpers; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows executable-adjacent installs such as `%LOCALAPPDATA%\Tolaria`, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use a vault-neutral entry with `WS_UI_PORT=9711`. Linux AppImage startup must extract `mcp-server/` to `~/.local/share/tolaria/mcp-server/` before durable registration uses that stable path. App-owned bridge launches still pass `VAULT_PATH`/`VAULT_PATHS`; durable external registrations rely on the Node MCP server reading `vaults.json` at tool-call time.
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
5. **Process lifecycle**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit
5. **OpenCode compatibility**: Keep `~/.config/opencode/opencode.json` in durable registration. OpenCode uses the top-level `mcp` key, `command` as an array, `environment` for env vars, `type: "local"`, and `enabled: true`; it must remain vault-neutral like the standard `mcpServers` entry.
6. **Process lifecycle and vault guidance**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit. MCP context must include root `AGENTS.md` instructions for every active mounted workspace when those files exist.

View File

@@ -0,0 +1,24 @@
# ADR-0117: Bundle the fcitx GTK3 frontend in Linux AppImages
## Status
Accepted
## Context
Linux AppImages run WebKitGTK through the GTK3 input-method stack. Users with fcitx5 can export `GTK_IM_MODULE=fcitx`, `QT_IM_MODULE=fcitx`, and `XMODIFIERS=@im=fcitx`, but the AppImage still cannot load the host GTK immodule reliably because the GTK module cache and library paths are isolated from the mounted AppDir.
The previous AppImage startup fallback set `GTK_IM_MODULE=fcitx` when fcitx was detected, but it did not make the `im-fcitx5.so` module available inside the AppImage. That left Chinese/Japanese/Korean input dependent on host paths that GTK may not search from a sealed AppImage.
## Decision
Linux release jobs install `fcitx5-frontend-gtk3` and the AppImage output-plugin shim copies the GTK3 fcitx immodule plus its client library into the AppDir before the AppImage is sealed. At runtime, AppImage startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache that points GTK at the bundled module whenever fcitx is configured explicitly or through common fcitx environment hints.
The sealed AppImage validation step extracts every produced AppImage and fails the release if the symlink-safe AppRun resolver, the bundled fcitx immodule, or the fcitx client library is missing.
## Consequences
- fcitx5 input works in AppImage launches without relying on the host GTK immodule cache path.
- X11 fallback launches with explicit `GTK_IM_MODULE=fcitx` use the same bundled module path as Wayland launches.
- Linux AppImage builds now depend on the distro package that provides the GTK3 fcitx frontend.
- If the Ubuntu package path changes, the AppImage validation step fails before publishing a broken bundle.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0118"
title: "Entry-scoped note windows without vault index scans"
status: active
date: 2026-05-14
---
## Context
ADR-0031 kept secondary note windows on the full `App` shell so they would inherit the same editor capabilities as the primary window. That decision also accepted a full vault load per secondary window as the simpler trade-off.
In practice, repeated note-window opens were paying that full-vault scan cost even when the window only needed one known note path. The startup path loaded the vault index and passed related entries into the editor even though note-window mode renders a single-note surface. That extra work increased window-open cost and made every secondary window depend on repository-wide entry hydration for a workflow that is intentionally scoped to one note.
Tolaria still wants note windows to reuse the main App architecture rather than reviving a separate `NoteWindow` shell. The missing decision was how far the shared vault loader should go when the window contract is already narrowed to a single entry.
## Decision
**Secondary note windows continue to render the full `App` shell, but they no longer load the full vault index during startup.** In note-window mode, Tolaria skips the shared vault-entry scan, reloads only the requested note entry, and scopes editor entry props to that active note instead of passing repository-wide visible entries.
This keeps the architectural benefit of ADR-0031 (one window architecture, one editor surface) while changing the data-loading contract for secondary windows from vault-scoped to entry-scoped.
## Alternatives considered
- **Full `App` shell with entry-scoped loading** (chosen): preserves feature parity in the shared shell while removing unnecessary full-vault scans for a one-note window. Trade-off: note windows should not assume vault-index-derived context is available by default.
- **Full `App` shell with full vault scan**: simplest continuation of ADR-0031, but repeats avoidable repository-wide I/O every time a note window opens.
- **Dedicated `NoteWindow` shell**: could be lighter still, but reintroduces the architectural drift and duplicated feature work that ADR-0031 intentionally removed.
## Consequences
- Opening a secondary note window no longer requires `list_vault`/full entry hydration before the editor can render the requested note.
- Repeated note-window opens avoid redundant vault scans and stay aligned with the product contract that these windows are single-note work surfaces.
- Features inside note-window mode must treat vault-index-derived data as opt-in; they cannot assume related entries are already loaded just because the full `App` shell is mounted.
- ADR-0031 remains directionally valid for shared window architecture, but its original "full vault load per secondary window" trade-off is no longer the operating model.
- Re-evaluate if note windows later need immediate repository-wide browsing context, or if future profiling shows the remaining single-entry reload path is still too expensive.

View File

@@ -0,0 +1,35 @@
# 0119. Vault-Neutral MCP Registration With Mounted Workspace Guidance
Status: active
Date: 2026-05-14
## Context
Tolaria used to register external MCP clients with a durable `VAULT_PATH` environment variable. That made the copied config easy to inspect, but it also meant Claude Code, Cursor, Gemini CLI, and generic MCP clients stayed pinned to whichever vault was active at setup time.
Domenico Lupinetti's dynamic-vault MCP proposal in PR #603 pointed in the right direction: MCP clients should follow Tolaria's current workspace state instead of requiring users to reconnect after each vault change. The current app model has also moved from one selected vault toward mounted workspaces, so the MCP server needs to operate on every active mounted vault and load local agent guidance from each vault.
## Decision
Durable external MCP registration is vault-neutral. Tolaria still writes an explicit stdio MCP entry, but that entry contains only the Node command, `mcp-server/index.js`, and `WS_UI_PORT=9711`. It no longer writes `VAULT_PATH`.
The Node MCP entrypoints resolve vaults at tool-call time:
- Explicit `VAULT_PATH` and `VAULT_PATHS` environment variables continue to win for app-owned bridge launches and legacy/manual launches.
- When those env vars are absent, the MCP server reads Tolaria's `vaults.json`.
- `active_vault` is returned first.
- Every workspace in `vaults[]` is included unless it is explicitly marked `mounted: false`.
- Paths are deduplicated and blank paths are ignored.
Vault context now checks each active mounted workspace root for `AGENTS.md` and returns those instructions with the vault summary. The MCP server also exposes `list_vaults` so agents can discover the active workspace set and whether each vault has root guidance.
We are not adding a session-local `switch_vault` tool. A switch tool would create a second source of truth inside the MCP process, while Tolaria already owns mounted workspace state.
## Consequences
External MCP config survives vault switches and mounted-workspace changes without reconnecting.
Agents can work across all active mounted vaults and receive the per-vault `AGENTS.md` instructions needed to respect local rules.
Manual users can still override the resolved workspace set with `VAULT_PATH` or `VAULT_PATHS` when they intentionally want a static or scripted MCP session.

View File

@@ -0,0 +1,37 @@
# 0120. Stable AppImage MCP Server Path With OpenCode Registration
Status: active
Date: 2026-05-14
## Context
Domenico Lupinetti's PR #600 identified two gaps in durable external MCP setup:
- Linux AppImage launches expose bundled resources through a mount path that can change between app starts, so external clients can keep a stale `mcp-server/index.js` path.
- OpenCode uses `~/.config/opencode/opencode.json` with a different MCP schema from Claude Code, Cursor, Gemini, and generic `mcpServers` clients.
ADR-0119 made durable MCP registration vault-neutral, so PR #600 could not be merged directly: its registered entries still carried `VAULT_PATH`. The stable-path and OpenCode work is still valid, but it has to preserve the current mounted-workspace resolution model.
## Decision
On Linux AppImage startup, Tolaria extracts the bundled `mcp-server/` directory to `~/.local/share/tolaria/mcp-server/`. The extracted directory is version-gated by a `.tolaria-version` marker. Extraction runs on first launch or after an app version change, uses a staging directory plus rename, and uses a process lock so concurrent app launches do not write the stable directory at the same time.
Durable external registration prefers the stable extracted server directory when it is ready. Otherwise it falls back to the packaged resource resolver.
OpenCode is added to durable MCP registration and removal. Tolaria writes an OpenCode-specific entry under the top-level `mcp` key using:
- `type: "local"`
- `command: [node, index.js]`
- `enabled: true`
- `environment.WS_UI_PORT = "9711"`
OpenCode registration remains vault-neutral. It does not write `VAULT_PATH`; the Node MCP server resolves active mounted workspaces from Tolaria state per ADR-0119.
## Consequences
Linux AppImage users can register external MCP clients once and keep a valid `index.js` path across restarts and updates.
OpenCode participates in the same connect, disconnect, and status flow as Claude Code, Cursor, Gemini, and generic MCP clients while preserving its own config schema.
The stable path fixes the packaging lifecycle without reintroducing static vault pinning.

View File

@@ -169,3 +169,6 @@ proposed → active → superseded
| [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active |
| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active |
| [0116](0116-rich-raw-transition-and-serialization-ownership.md) | Rich/raw transition and serialization ownership | active |
| [0118](0118-entry-scoped-note-windows-without-vault-index-scans.md) | Entry-scoped note windows without vault index scans | active |
| [0119](0119-vault-neutral-mcp-registration-with-mounted-workspace-guidance.md) | Vault-neutral MCP registration with mounted workspace guidance | active |
| [0120](0120-stable-appimage-mcp-server-path-with-opencode-registration.md) | Stable AppImage MCP server path with OpenCode registration | active |

View File

@@ -62,6 +62,9 @@ files:
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
git.repository.select: 33fcf2b3ec4686d9cd06051c726d0ba2
git.toast.autoGitFailed: 3e5d7e183a78e1f54c87ad0dba8a302b
git.toast.commitFailed: bb50986d29c30042bed1446f8adaa544
git.toast.missingAuthor: 0d4e7a7d6b6a3f883146dd86c9cdca09
command.view.editorOnly: 8355e056b32086b9190d639be290dda8
command.view.editorNoteList: b9604b1609eb6f581cd9570dca72d3f0
command.view.fullLayout: 1cebdf839eee2b1713828731aaa3b507
@@ -124,6 +127,8 @@ files:
settings.workspaces.labelTooltip: b1b87a2affcd504e54bf97100e3d9c34
settings.workspaces.slugTooltip: daa690f5855e35d1cfa9dbffe1870d40
settings.workspaces.color: cb5feb1b7314637725a2e73bdc9f7295
settings.workspaces.moveUpAria: 2ade43872ef9432d14af83056304c6d7
settings.workspaces.moveDownAria: ed1ef4a310797ac84894a224bf4c77a6
settings.workspaces.removeAria: a5d2ceeeb76bfcb99015fc780ced07c2
settings.appearance.title: a1c58e94227389415de133efdf78ea6e
settings.appearance.description: ca7b75a4c10ab8a540707e3a96cd3592
@@ -149,7 +154,7 @@ files:
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e
settings.vaultContent.title: 78b883ce9acb9c25e611158a518d9185
settings.vaultContent.title: f15c1cae7882448b3fb0404682e17e61
settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d
settings.dateDisplay.default: 915e8ca934abe6625e17695ee7ae266a
settings.dateDisplay.defaultDescription: 502ed7ff7b9c0fe5971e8cb0576e91f6

View File

@@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { vaultContext } from './vault.js'
export async function readAgentInstructions(vaultPath) {
const instructionsPath = path.join(vaultPath, 'AGENTS.md')
try {
return {
path: instructionsPath,
content: await readFile(instructionsPath, 'utf8'),
}
} catch (error) {
if (error?.code === 'ENOENT') return null
throw error
}
}
export async function vaultContextWithInstructions(vaultPath) {
return {
...(await vaultContext(vaultPath)),
agentInstructions: await readAgentInstructions(vaultPath),
}
}

View File

@@ -19,12 +19,11 @@ import {
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import { searchNotes, getNote, vaultContext } from './vault.js'
import { searchNotes, getNote } from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
const VAULT_PATHS = requireVaultPaths()
const PRIMARY_VAULT_PATH = VAULT_PATHS[0]
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
@@ -35,6 +34,10 @@ let reconnectTimer = null
let shutdownStarted = false
const RECONNECT_INTERVAL_MS = 3000
function activeVaultPaths() {
return requireVaultPaths()
}
function connectUiBridge() {
if (shutdownStarted) return
@@ -115,7 +118,7 @@ const TOOLS = [
},
{
name: 'get_vault_context',
description: 'Get vault orientation for the active Tolaria vaults: entity types, note count, folders, and recent notes.',
description: 'Get vault orientation for the active Tolaria vaults: entity types, AGENTS.md instructions, note count, folders, and recent notes.',
inputSchema: {
type: 'object',
properties: {
@@ -123,6 +126,14 @@ const TOOLS = [
},
},
},
{
name: 'list_vaults',
description: 'List the current active Tolaria vaults available to MCP tools, including whether each vault has AGENTS.md instructions.',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'get_note',
description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.',
@@ -175,7 +186,7 @@ const TOOLS = [
function requestedVaultPath(args = {}) {
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
if (!requested) return null
if (!VAULT_PATHS.includes(requested)) {
if (!activeVaultPaths().includes(requested)) {
throw new Error(`Vault is not active in Tolaria: ${requested}`)
}
return requested
@@ -194,7 +205,7 @@ function withVaultMetadata(note, vaultPath) {
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : VAULT_PATHS
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
const errors = []
@@ -217,7 +228,7 @@ async function searchActiveVaults(query, limit = 10) {
const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10
const results = []
for (const vaultPath of VAULT_PATHS) {
for (const vaultPath of activeVaultPaths()) {
const vaultResults = await searchNotes(vaultPath, query, requestedLimit)
results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath)))
if (results.length >= requestedLimit) break
@@ -227,18 +238,20 @@ async function searchActiveVaults(query, limit = 10) {
}
async function activeVaultContext(targetVaultPath = null) {
if (targetVaultPath) return vaultContext(targetVaultPath)
if (VAULT_PATHS.length === 1) return vaultContext(PRIMARY_VAULT_PATH)
const roots = activeVaultPaths()
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return {
vaults: await Promise.all(VAULT_PATHS.map(vaultContext)),
vaults: await Promise.all(roots.map(vaultContextWithInstructions)),
}
}
function uiPath(args = {}) {
const notePath = typeof args.path === 'string' ? args.path : ''
if (path.isAbsolute(notePath)) return notePath
const vaultPath = requestedVaultPath(args) ?? (VAULT_PATHS.length === 1 ? PRIMARY_VAULT_PATH : '')
const roots = activeVaultPaths()
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
@@ -255,6 +268,20 @@ async function handleVaultContext(args = {}) {
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
}
async function handleListVaults() {
const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => {
const agentInstructions = await readAgentInstructions(vaultPath)
return {
path: vaultPath,
label: vaultLabel(vaultPath),
agentInstructionsPath: agentInstructions?.path ?? null,
hasAgentInstructions: agentInstructions !== null,
}
}))
return { content: [{ type: 'text', text: JSON.stringify({ vaults }, null, 2) }] }
}
async function handleGetNote(args) {
const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args))
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
@@ -279,23 +306,20 @@ function handleRefreshVault(args) {
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
}
const TOOL_HANDLERS = new Map([
['search_notes', handleSearchNotes],
['get_vault_context', handleVaultContext],
['list_vaults', handleListVaults],
['get_note', handleGetNote],
['open_note', handleOpenNote],
['highlight_editor', handleHighlightEditor],
['refresh_vault', handleRefreshVault],
])
function callToolHandler(name, args) {
switch (name) {
case 'search_notes':
return handleSearchNotes(args)
case 'get_vault_context':
return handleVaultContext(args)
case 'get_note':
return handleGetNote(args)
case 'open_note':
return handleOpenNote(args)
case 'highlight_editor':
return handleHighlightEditor(args)
case 'refresh_vault':
return handleRefreshVault(args)
default:
throw new Error(`Unknown tool: ${name}`)
}
const handler = TOOL_HANDLERS.get(name)
if (!handler) throw new Error(`Unknown tool: ${name}`)
return handler(args)
}
// --- Server setup ---
@@ -358,7 +382,7 @@ async function main() {
connectUiBridge()
await server.connect(transport)
console.error(`Tolaria MCP server running (vaults: ${VAULT_PATHS.join(', ')})`)
console.error('Tolaria MCP server running (vaults resolved per call)')
}
main().catch((error) => {

View File

@@ -2,7 +2,7 @@ import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import {
mkdtemp, mkdir, open, rm,
mkdtemp, mkdir, open, rm, writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
@@ -13,6 +13,7 @@ import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { requireVaultPath, requireVaultPaths } from './vault-path.js'
import { vaultContextWithInstructions } from './agent-instructions.js'
import { evaluateBridgeRequest } from './ws-bridge.js'
let tmpDir
@@ -191,6 +192,26 @@ describe('vaultContext', () => {
const ctx = await vaultContext(tmpDir)
assert.equal(ctx.noteCount, 4)
})
it('includes root AGENTS.md instructions when present', async () => {
const agentsPath = path.join(tmpDir, 'AGENTS.md')
await writeFile(agentsPath, '# Vault Rules\n\nUse this vault carefully.\n', 'utf-8')
try {
const ctx = await vaultContextWithInstructions(tmpDir)
assert.deepEqual(ctx.agentInstructions, {
path: agentsPath,
content: '# Vault Rules\n\nUse this vault carefully.\n',
})
} finally {
await rm(agentsPath, { force: true })
}
})
it('reports null agent instructions when AGENTS.md is absent', async () => {
const ctx = await vaultContextWithInstructions(tmpDir)
assert.equal(ctx.agentInstructions, null)
})
})
describe('evaluateBridgeRequest', () => {
@@ -236,11 +257,13 @@ describe('requireVaultPath', () => {
)
})
it('rejects missing vault paths instead of falling back to ~/Laputa', () => {
it('rejects missing vault paths instead of falling back to ~/Laputa', async () => {
const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-empty-config-'))
assert.throws(
() => requireVaultPath({}),
() => requireVaultPaths({}, { configDir }),
/VAULT_PATH is required/,
)
await rm(configDir, { recursive: true, force: true })
})
it('returns all configured active vault paths with the primary vault first', () => {
@@ -252,6 +275,33 @@ describe('requireVaultPath', () => {
['/tmp/Default Vault', '/tmp/Second Vault'],
)
})
it('loads active mounted vault paths from Tolaria config when env is vault-neutral', async () => {
const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-config-'))
const primaryVault = path.join(configDir, 'Primary Vault')
const secondaryVault = path.join(configDir, 'Secondary Vault')
const hiddenVault = path.join(configDir, 'Hidden Vault')
const configPath = path.join(configDir, 'com.tolaria.app', 'vaults.json')
await mkdir(path.dirname(configPath), { recursive: true })
await writeFile(configPath, JSON.stringify({
active_vault: primaryVault,
vaults: [
{ label: 'Secondary', path: secondaryVault, mounted: true },
{ label: 'Hidden', path: hiddenVault, mounted: false },
{ label: 'Primary', path: primaryVault, mounted: true },
],
}), 'utf-8')
try {
assert.deepEqual(
requireVaultPaths({}, { configDir }),
[primaryVault, secondaryVault],
)
} finally {
await rm(configDir, { recursive: true, force: true })
}
})
})
describe('stdio process lifecycle', () => {

View File

@@ -1,3 +1,10 @@
import { existsSync, readFileSync } from 'node:fs'
import { homedir, platform } from 'node:os'
import { join } from 'node:path'
const APP_CONFIG_DIR = 'com.tolaria.app'
const LEGACY_APP_CONFIG_DIR = 'com.laputa.app'
function parseVaultPathList(rawValue) {
if (!rawValue?.trim()) return []
@@ -24,17 +31,58 @@ function uniqueVaultPaths(paths) {
return unique
}
export function requireVaultPaths(env = process.env) {
function appConfigBaseDir(env = process.env) {
if (platform() === 'darwin') return join(homedir(), 'Library', 'Application Support')
if (platform() === 'win32') return env.APPDATA || join(homedir(), 'AppData', 'Roaming')
return env.XDG_CONFIG_HOME || join(homedir(), '.config')
}
export function vaultsJsonPath({ configDir = appConfigBaseDir() } = {}) {
const preferred = join(configDir, APP_CONFIG_DIR, 'vaults.json')
if (existsSync(preferred)) return preferred
const legacy = join(configDir, LEGACY_APP_CONFIG_DIR, 'vaults.json')
return existsSync(legacy) ? legacy : preferred
}
function pushUniquePath(paths, value) {
const path = typeof value === 'string' ? value.trim() : ''
if (!path || paths.includes(path)) return
paths.push(path)
}
function activeVaultPathsFromList(list) {
const paths = []
pushUniquePath(paths, list?.active_vault)
for (const vault of list?.vaults ?? []) {
if (vault?.mounted === false) continue
pushUniquePath(paths, vault?.path)
}
return paths
}
export function configuredVaultPaths({ configDir } = {}) {
const filePath = vaultsJsonPath({ configDir })
if (!existsSync(filePath)) return []
return activeVaultPathsFromList(JSON.parse(readFileSync(filePath, 'utf-8')))
}
export function requireVaultPaths(env = process.env, options = {}) {
const vaultPaths = uniqueVaultPaths([
env.VAULT_PATH?.trim() ?? '',
...parseVaultPathList(env.VAULT_PATHS),
])
if (vaultPaths.length === 0) {
const configuredPaths = configuredVaultPaths(options)
if (configuredPaths.length > 0) return configuredPaths
throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.')
}
return vaultPaths
}
export function requireVaultPath(env = process.env) {
return requireVaultPaths(env)[0]
export function requireVaultPath(env = process.env, options = {}) {
return requireVaultPaths(env, options)[0]
}

View File

@@ -22,9 +22,10 @@
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import {
getNote, searchNotes, vaultContext,
getNote, searchNotes,
} from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
@@ -38,16 +39,10 @@ const TRUSTED_UI_ORIGINS = new Set([
/** @type {WebSocketServer | null} */
let uiBridge = null
let vaultPaths = null
const UNKNOWN_TOOL = Symbol('unknown tool')
function activeVaultPaths() {
vaultPaths ??= requireVaultPaths()
return vaultPaths
}
function primaryVaultPath() {
return activeVaultPaths()[0]
return requireVaultPaths()
}
function requestedVaultPath(args = {}) {
@@ -63,7 +58,7 @@ function uiPath(args = {}) {
const notePath = typeof args.path === 'string' ? args.path : ''
if (path.isAbsolute(notePath)) return notePath
const roots = activeVaultPaths()
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? primaryVaultPath() : '')
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
@@ -102,8 +97,8 @@ async function searchActiveVaults(query, limit = 10) {
async function activeVaultContext() {
const roots = activeVaultPaths()
if (roots.length === 1) return vaultContext(roots[0])
return { vaults: await Promise.all(roots.map(vaultContext)) }
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return { vaults: await Promise.all(roots.map(vaultContextWithInstructions)) }
}
function broadcastUiAction(action, payload) {
@@ -149,11 +144,26 @@ function refreshVaultTool(args) {
return { ok: true }
}
async function listVaultsTool() {
return {
vaults: await Promise.all(activeVaultPaths().map(async (vaultPath) => {
const agentInstructions = await readAgentInstructions(vaultPath)
return {
path: vaultPath,
label: path.basename(vaultPath) || vaultPath,
agentInstructionsPath: agentInstructions?.path ?? null,
hasAgentInstructions: agentInstructions !== null,
}
})),
}
}
const TOOL_EXECUTORS = [
['open_note', readNoteTool],
['read_note', readNoteTool],
['search_notes', (args) => searchActiveVaults(args.query, args.limit)],
['vault_context', () => activeVaultContext()],
['list_vaults', () => listVaultsTool()],
['ui_open_note', uiOpenNoteTool],
['ui_open_tab', uiOpenTabTool],
['ui_highlight', highlightTool],

View File

@@ -65,7 +65,6 @@
"date-fns": "^4.1.0",
"dompurify": "3.4.2",
"katex": "^0.16.28",
"lucide-react": "^0.564.0",
"mermaid": "^11.14.0",
"posthog-js": "^1.363.5",
"radix-ui": "^1.4.3",

View File

@@ -241,7 +241,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..ee009c533fa3826819df0b83a12755b6
e.state.draggingState = void 0, e.emitUpdate(), n.transact((o) => o.setMeta(D, null)), !n.headless && Ut(n.prosemirrorView.root);
},
/**
@@ -1918,30 +1961,37 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
@@ -1918,30 +1961,38 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
setCellSelection(o, r, s = r) {
if (!e)
throw new Error("Table handles view not initialized");
@@ -292,8 +292,9 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..ee009c533fa3826819df0b83a12755b6
+ return e == null || e.hideHandles({ resetCell: !0 }), !1;
return r.orientation === "row" ? r.side === "above" ? pt(a, i) : ft(a, i) : r.side === "left" ? gt(a, i) : wt(a, i);
});
+ e == null || e.hideHandles({ resetCell: !0, resetDragging: !0 });
},
@@ -1949,17 +1999,23 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
@@ -1949,17 +2000,23 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* Removes a row or column from the table using prosemirror-table commands
*/
removeRowOrColumn(o, r) {
@@ -722,7 +723,7 @@ index 029103600a98bf8ccd3054a5c43ffa2fd9115c06..d1678ed4761baca0b3495dafc8d75152
destroy() {
diff --git a/src/extensions/TableHandles/TableHandles.ts b/src/extensions/TableHandles/TableHandles.ts
index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a4c805e27 100644
index 3063774..9813bed 100644
--- a/src/extensions/TableHandles/TableHandles.ts
+++ b/src/extensions/TableHandles/TableHandles.ts
@@ -99,6 +99,46 @@ function getChildIndex(node: Element) {
@@ -1035,7 +1036,15 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
if (direction.orientation === "row") {
if (direction.side === "above") {
return addRowBefore(state, dispatch);
@@ -1006,12 +1081,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -997,6 +1072,7 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
}
}
});
+ view?.hideHandles({ resetCell: true, resetDragging: true });
},
/**
@@ -1006,12 +1082,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
index: RelativeCellIndices["row"] | RelativeCellIndices["col"],
direction: "row" | "column",
) {
@@ -1055,7 +1064,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
return deleteRow(state, dispatch);
});
} else {
@@ -1020,6 +1102,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -1020,6 +1103,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
row: 0,
col: index,
});
@@ -1065,7 +1074,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
return deleteColumn(state, dispatch);
});
}
@@ -1041,6 +1126,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -1041,6 +1127,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
)
: beforeState;
@@ -1076,7 +1085,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..25b298e6a2685f3667ee53b95175912a
return mergeCells(state, dispatch);
});
},
@@ -1055,6 +1144,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -1055,6 +1145,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
? this.setCellSelection(beforeState, relativeCellToSplit)
: beforeState;

View File

@@ -1,5 +1,5 @@
diff --git a/dist/blocknote-react.js b/dist/blocknote-react.js
index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf6319df91544 100644
index d4d36cd..79dd4f0 100644
--- a/dist/blocknote-react.js
+++ b/dist/blocknote-react.js
@@ -154,8 +154,26 @@ const co = (e) => {
@@ -31,7 +31,15 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
}
const z = (e) => {
var h, b, p;
@@ -216,9 +234,7 @@ const z = (e) => {
@@ -193,6 +211,7 @@ const z = (e) => {
...e.elementProps,
style: {
display: "flex",
+ pointerEvents: c === "close" ? "none" : void 0,
...(h = e.elementProps) == null ? void 0 : h.style,
zIndex: `calc(var(--bn-ui-base-z-index) + ${((p = (b = e.elementProps) == null ? void 0 : b.style) == null ? void 0 : p.zIndex) || 0})`,
...n,
@@ -216,9 +235,7 @@ const z = (e) => {
const s = Ue(t, c.doc);
if (!s)
return;
@@ -42,7 +50,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
if (a instanceof Element)
return {
element: a
@@ -2499,7 +2515,14 @@ function Kr(e) {
@@ -2499,7 +2516,14 @@ function Kr(e) {
columns: d
} = e, m = H(
(C) => {
@@ -58,7 +66,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
},
[u, a, s]
), { items: g, usedQuery: f, loadingState: h } = Yt(
@@ -2734,7 +2757,14 @@ function ti(e) {
@@ -2734,7 +2758,14 @@ function ti(e) {
onItemClick: u
} = e, d = H(
(p) => {
@@ -74,7 +82,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
},
[u, a, s]
), { items: m, usedQuery: g, loadingState: f } = Yt(
@@ -3306,14 +3336,15 @@ const ii = (e, t = 0.3) => {
@@ -3306,14 +3337,15 @@ const ii = (e, t = 0.3) => {
);
if (!m)
return {};
@@ -93,7 +101,7 @@ index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf631
return p instanceof Element ? (d.cellReference = { element: p }, d.rowReference = {
element: f,
getBoundingClientRect: () => {
@@ -4371,7 +4402,7 @@ const zi = (e) => {
@@ -4371,7 +4403,7 @@ const zi = (e) => {
const a = Ue(t, c.prosemirrorState.doc);
if (!a)
return;

36
pnpm-lock.yaml generated
View File

@@ -19,10 +19,10 @@ overrides:
patchedDependencies:
'@blocknote/core@0.46.2':
hash: e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0
hash: 9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: 9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08
hash: 67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e
path: patches/@blocknote__react@0.46.2.patch
'@tiptap/extension-link@3.19.0':
hash: fbe59b1b8b798ba3fefa371c0729b5aa3458e1ea70a4b86cd5267f9f62529284
@@ -40,16 +40,16 @@ 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=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@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=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@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=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@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)
'@blocknote/react':
specifier: ^0.46.2
version: 0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@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)
version: 0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@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)
'@codemirror/commands':
specifier: ^6.10.2
version: 6.10.2
@@ -146,9 +146,6 @@ importers:
katex:
specifier: ^0.16.28
version: 0.16.28
lucide-react:
specifier: ^0.564.0
version: 0.564.0(react@19.2.4)
mermaid:
specifier: ^11.14.0
version: 11.14.0
@@ -4561,11 +4558,6 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.564.0:
resolution: {integrity: sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -6219,9 +6211,9 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@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=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@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
@@ -6229,7 +6221,7 @@ snapshots:
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@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)
@@ -6281,8 +6273,8 @@ 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=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@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)
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@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)
'@mantine/utils': 6.0.22(react@19.2.4)
@@ -6301,9 +6293,9 @@ snapshots:
- sugar-high
- supports-color
'@blocknote/react@0.46.2(patch_hash=9721936e806f31cf440cc94ead3ec03764ae968870c167d086a8e60d8f6d4a08)(@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)':
'@blocknote/react@0.46.2(patch_hash=67ee7211dff79c50d821c3145b0c96b42bb9a614d508edafd65492b6b5ccb41e)(@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=e617eb2c92c28d2eefc0b4900540238646e5c32c6e22ff09657b1d306e8b70e0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=9a05ab63f78eff8887ea009f15116e0e7e70de00eaba31093f3f9584da364df2)(@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
@@ -10519,10 +10511,6 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.564.0(react@19.2.4):
dependencies:
react: 19.2.4
lz-string@1.5.0: {}
magic-string@0.30.21:

View File

@@ -0,0 +1,21 @@
## New Features
- 🧮 **Math Blocks from Slash Commands** — Insert math expressions directly from the editor slash menu.
- 🗓️ **Flexible Date Display** — Choose how dates appear across note lists, properties, and inspector views.
- 🧭 **Mounted Workspaces** — Bring separate vault workspaces into Tolaria while keeping their Git boundaries intact.
- 📂 **Collapsed Sidebar Reopen Button** — Reopen the sidebar more easily after switching to a focused writing layout.
## Improvements
- 🖼️ **Better Media and Embed Handling** — Note-relative images, multimedia URLs, file blocks, and rich-editor media reloads behave more consistently.
- 🤖 **More Reliable Local Agent Launches** — Claude, Gemini, OpenCode, and MCP server setup now handle modern CLI behavior and Windows paths more gracefully.
- 🧱 **Smoother Editor Navigation** — Breadcrumb titles, neighborhood toggles, previous-list recovery, note renaming, and editor reloads now preserve more context.
- 🌐 **Cleaner Release and Docs Publishing** — Release pages, GitHub Pages deployment, custom-domain docs, and updater metadata were hardened.
## Stability and Fixes
- Whiteboard dialogs, mermaid previews, tldraw context menus, and dark-mode rendering are more resilient.
- The editor now guards more stale-selection, stale-block, paste, tab-switch, and save-before-switch edge cases.
- Security and quality hardening resolved multiple Codacy findings around mock vault APIs, regexes, object injection, focus handling, and history flows.
- Test coverage was expanded for note history edits, telemetry onboarding, rich-editor media reloads, Cmd+N persistence, save-before-switch behavior, and editor draft preservation.
- Internal app orchestration, vault setup, inbox advance, and Git file workflow code was split into smaller focused pieces without changing the released user model.

View File

@@ -0,0 +1,17 @@
## New Features
- 🗂️ **Custom Vault Order** — Reorder your vaults so the switcher and workspace controls match the way you actually work.
- 🔌 **Smarter External MCP Setup** — Register Tolarias external MCP server with dynamic vault resolution, including mounted-workspace guidance for AppImage users.
## Improvements
-**Faster Large-Note Opens** — Warm parsed editor blocks ahead of note switches so large notes feel more responsive.
- 🧭 **Clearer Vault and Status Controls** — Use cleaner status-bar sections, vault menus, and workspace settings rows when managing multiple vaults.
- 🐧 **Stronger Linux AppImage Support** — Improve AppImage launch, packaging, FUSE, fcitx input, and MCP subprocess handling across Linux setups.
- 🎨 **More Consistent Interface Icons** — Standardize icons on the Phosphor set for a cleaner and more coherent UI.
## Stability and Fixes
- This release fixes relationship equality filters, externally moved inbox notes, selected-vault note creation, and duplicate vault folder pickers.
- Editor reliability is improved around pulled changes, file attachments, CJK code-block copying, table handles after reload, Mermaid errors, and procedure editor schema recovery.
- Whiteboard dialogs, native commit messages, AutoGit author failures, fullscreen navigation, Linux Wayland safeguards, and release history links were hardened.

View File

@@ -0,0 +1,329 @@
import { spawnSync } from 'node:child_process'
import { error as logError, log } from 'node:console'
import { existsSync } from 'node:fs'
import {
chmod,
mkdir,
mkdtemp,
readFile,
rename,
rm,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
export const BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE =
'this_dir="$(readlink -f "$(dirname "$0")")"'
export const FIXED_LINUXDEPLOY_APPRUN_DIR_LINE =
'this_dir="$(dirname "$(readlink -f "$0")")"'
export const APPIMAGE_PLUGIN_WRAPPER_NAME = 'linuxdeploy-plugin-appimage.AppImage'
export const REAL_APPIMAGE_PLUGIN_NAME =
'tolaria-real-linuxdeploy-plugin-appimage/linuxdeploy-plugin-appimage.AppImage'
export const APPIMAGE_FCITX_GTK3_IM_MODULE_PATH =
'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so'
export const APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH =
'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2'
export const DEFAULT_APPIMAGE_PLUGIN_URL =
'https://github.com/linuxdeploy/linuxdeploy-plugin-appimage/releases/download/continuous/linuxdeploy-plugin-appimage-x86_64.AppImage'
const WRAPPER_MARKER = 'Tolaria AppImage symlink launcher shim'
const REQUIRED_APPIMAGE_PATHS = [
'AppRun',
APPIMAGE_FCITX_GTK3_IM_MODULE_PATH,
APPIMAGE_FCITX_GCLIENT_LIBRARY_PATH,
]
export function tauriToolsCacheDir(env = process.env) {
if (env.TOLARIA_TAURI_TOOLS_DIR) {
return resolve(env.TOLARIA_TAURI_TOOLS_DIR)
}
if (env.XDG_CACHE_HOME) {
return resolve(env.XDG_CACHE_HOME, 'tauri')
}
if (!env.HOME) {
throw new Error('HOME or XDG_CACHE_HOME is required to locate the Tauri tools cache')
}
return resolve(env.HOME, '.cache', 'tauri')
}
export function patchAppRunText(text) {
if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) {
return {
changed: true,
text: text.replaceAll(
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
FIXED_LINUXDEPLOY_APPRUN_DIR_LINE,
),
}
}
return { changed: false, text }
}
export function assertSymlinkSafeAppRunText(text, label = 'AppRun') {
if (text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE)) {
throw new Error(`${label} still resolves dirname before following AppRun symlinks`)
}
if (!text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE)) {
throw new Error(`${label} is missing the symlink-safe AppRun directory resolver`)
}
}
export function appImagePluginWrapperSource({
pluginUrl = DEFAULT_APPIMAGE_PLUGIN_URL,
} = {}) {
return `#!/usr/bin/env bash
set -euo pipefail
# ${WRAPPER_MARKER}
PLUGIN_URL="\${TOLARIA_APPIMAGE_PLUGIN_URL:-${pluginUrl}}"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REAL_PLUGIN="\${TOLARIA_APPIMAGE_REAL_PLUGIN:-"$SCRIPT_DIR/${REAL_APPIMAGE_PLUGIN_NAME}"}"
FCITX_GTK3_IM_MODULE="\${TOLARIA_FCITX_GTK3_IM_MODULE:-/usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so}"
FCITX_LIBRARY_DIR="\${TOLARIA_FCITX_LIBRARY_DIR:-/usr/lib/x86_64-linux-gnu}"
appdir_from_args() {
local previous=""
for arg in "$@"; do
if [ "$previous" = "--appdir" ]; then
printf '%s\\n' "$arg"
return 0
fi
case "$arg" in
--appdir=*)
printf '%s\\n' "\${arg#--appdir=}"
return 0
;;
esac
previous="$arg"
done
}
download_real_plugin() {
if [ -x "$REAL_PLUGIN" ]; then
return 0
fi
local tmp_plugin="$REAL_PLUGIN.tmp.$$"
rm -f "$tmp_plugin"
mkdir -p "$(dirname -- "$REAL_PLUGIN")"
if command -v curl >/dev/null 2>&1; then
curl -fsSL -o "$tmp_plugin" "$PLUGIN_URL"
elif command -v wget >/dev/null 2>&1; then
wget -q -O "$tmp_plugin" "$PLUGIN_URL"
else
echo "curl or wget is required to fetch the real linuxdeploy AppImage output plugin" >&2
return 1
fi
chmod +x "$tmp_plugin"
mv "$tmp_plugin" "$REAL_PLUGIN"
}
patch_apprun() {
local appdir="\${APPDIR:-}"
if [ -z "$appdir" ]; then
appdir="$(appdir_from_args "$@" || true)"
fi
if [ -z "$appdir" ] || [ ! -f "$appdir/AppRun" ]; then
return 0
fi
python3 - "$appdir/AppRun" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
broken = 'this_dir="$(readlink -f "$(dirname "$0")")"'
fixed = 'this_dir="$(dirname "$(readlink -f "$0")")"'
text = path.read_text(encoding="utf-8")
if broken in text:
path.write_text(text.replace(broken, fixed), encoding="utf-8")
print(f"Patched linuxdeploy AppRun symlink resolution in {path}", file=sys.stderr)
elif fixed in text:
pass
elif "autogenerated by linuxdeploy" in text and "AppRun.wrapped" in text:
raise SystemExit(f"{path} is a linuxdeploy wrapper but does not contain the expected AppRun resolver")
PY
}
bundle_fcitx_gtk3_module() {
local appdir="\${APPDIR:-}"
if [ -z "$appdir" ]; then
appdir="$(appdir_from_args "$@" || true)"
fi
if [ -z "$appdir" ] || [ ! -d "$appdir" ] || [ ! -f "$FCITX_GTK3_IM_MODULE" ]; then
return 0
fi
local module_dest="$appdir/${APPIMAGE_FCITX_GTK3_IM_MODULE_PATH}"
local library_dest_dir="$appdir/usr/lib/x86_64-linux-gnu"
mkdir -p "$(dirname -- "$module_dest")" "$library_dest_dir"
cp -a "$FCITX_GTK3_IM_MODULE" "$module_dest"
local copied_library=0
shopt -s nullglob
for lib in "$FCITX_LIBRARY_DIR"/libFcitx5GClient.so* "$FCITX_LIBRARY_DIR"/libFcitx5Utils.so*; do
cp -a "$lib" "$library_dest_dir/"
copied_library=1
done
shopt -u nullglob
if [ "$copied_library" -eq 0 ]; then
echo "No fcitx GTK client libraries found in $FCITX_LIBRARY_DIR" >&2
fi
}
download_real_plugin
patch_apprun "$@"
bundle_fcitx_gtk3_module "$@"
exec "$REAL_PLUGIN" "$@"
`
}
export async function preparePluginWrapper({
env = process.env,
toolsDir = tauriToolsCacheDir(env),
} = {}) {
await mkdir(toolsDir, { recursive: true })
const wrapperPath = join(toolsDir, APPIMAGE_PLUGIN_WRAPPER_NAME)
const realPluginPath = join(toolsDir, REAL_APPIMAGE_PLUGIN_NAME)
if (existsSync(wrapperPath) && !existsSync(realPluginPath)) {
const existing = await readFile(wrapperPath, 'utf8').catch(() => '')
if (!existing.includes(WRAPPER_MARKER)) {
await mkdir(dirname(realPluginPath), { recursive: true })
await rename(wrapperPath, realPluginPath)
}
}
await writeFile(wrapperPath, appImagePluginWrapperSource(), 'utf8')
await chmod(wrapperPath, 0o755)
return { realPluginPath, wrapperPath }
}
export async function validateAppRunFile(path) {
const text = await readFile(path, 'utf8')
assertSymlinkSafeAppRunText(text, path)
}
function extractAppImagePath(appImage, requiredPath, tempDir) {
const result = spawnSync(appImage, ['--appimage-extract', requiredPath], {
cwd: tempDir,
encoding: 'utf8',
})
if (result.status === 0) {
return
}
throw new Error(
[
`Failed to extract ${requiredPath} from ${appImage}`,
result.stdout.trim(),
result.stderr.trim(),
]
.filter(Boolean)
.join('\n'),
)
}
function assertAppImagePathsExtracted(appImage, tempDir, requiredPaths) {
for (const requiredPath of requiredPaths) {
const extractedPath = join(tempDir, 'squashfs-root', requiredPath)
if (!existsSync(extractedPath)) {
throw new Error(`${appImage} is missing ${requiredPath}`)
}
}
}
async function validateExtractedAppImage(appImage, tempDir) {
for (const requiredPath of REQUIRED_APPIMAGE_PATHS) {
extractAppImagePath(appImage, requiredPath, tempDir)
}
await validateAppRunFile(join(tempDir, 'squashfs-root', 'AppRun'))
assertAppImagePathsExtracted(appImage, tempDir, REQUIRED_APPIMAGE_PATHS.slice(1))
}
export async function validateAppImages(paths) {
if (paths.length === 0) {
throw new Error('At least one AppImage path is required for launcher validation')
}
for (const appImage of paths.map((path) => resolve(path))) {
const tempDir = await mkdtemp(join(tmpdir(), 'tolaria-appimage-'))
try {
await validateExtractedAppImage(appImage, tempDir)
} finally {
await rm(tempDir, { recursive: true, force: true })
}
}
}
async function preparePluginCommand() {
const { wrapperPath, realPluginPath } = await preparePluginWrapper()
log(`Prepared ${wrapperPath}`)
log(`Real plugin cache: ${realPluginPath}`)
}
async function validateAppRunFilesCommand(paths) {
for (const path of paths) {
await validateAppRunFile(path)
log(`Validated ${path}`)
}
}
async function validateAppImagesCommand(paths) {
await validateAppImages(paths)
for (const path of paths) {
log(`Validated AppImage launcher in ${path}`)
}
}
const COMMANDS = new Map([
['prepare-plugin', preparePluginCommand],
['validate-apprun-file', validateAppRunFilesCommand],
['validate-appimages', validateAppImagesCommand],
])
function usage() {
return 'Usage: node scripts/appimage-launcher-tools.mjs prepare-plugin | validate-apprun-file <AppRun...> | validate-appimages <AppImage...>'
}
async function main() {
const [command, ...args] = process.argv.slice(2)
const handler = COMMANDS.get(command)
if (!handler) {
throw new Error(usage())
}
await handler(args)
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
logError(error.message)
process.exit(1)
})
}

View File

@@ -0,0 +1,182 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { existsSync, realpathSync } from 'node:fs'
import {
chmod,
mkdir,
mkdtemp,
readFile,
symlink,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import process from 'node:process'
import test from 'node:test'
import {
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
FIXED_LINUXDEPLOY_APPRUN_DIR_LINE,
REAL_APPIMAGE_PLUGIN_NAME,
appImagePluginWrapperSource,
patchAppRunText,
preparePluginWrapper,
} from './appimage-launcher-tools.mjs'
function brokenResolverDir(invokedPath) {
return realpathSync(dirname(invokedPath))
}
function fixedResolverDir(invokedPath) {
return dirname(realpathSync(invokedPath))
}
test('patches linuxdeploy AppRun wrapper to resolve the invoked path before dirname', () => {
const original = [
'#! /usr/bin/env bash',
'# autogenerated by linuxdeploy',
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
'exec "$this_dir"/AppRun.wrapped "$@"',
].join('\n')
const patched = patchAppRunText(original)
assert.equal(patched.changed, true)
assert.equal(patched.text.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false)
assert.equal(patched.text.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true)
})
test('fixed resolver follows absolute and relative symlinks before choosing AppDir', async () => {
const root = await mkdtemp(join(tmpdir(), 'tolaria-apprun-resolver-'))
const appDir = join(root, 'Tolaria.AppDir')
const binDir = join(root, 'bin')
const relativeDir = join(root, 'relative-bin')
const appRun = join(appDir, 'AppRun')
await mkdir(appDir)
await mkdir(binDir)
await mkdir(relativeDir)
await writeFile(appRun, '#! /usr/bin/env bash\n', 'utf8')
const absoluteSymlink = join(binDir, 'tolaria')
const relativeSymlink = join(relativeDir, 'tolaria')
await symlink(appRun, absoluteSymlink)
await symlink(`../${basename(appDir)}/AppRun`, relativeSymlink)
assert.equal(brokenResolverDir(absoluteSymlink), realpathSync(binDir))
assert.equal(fixedResolverDir(absoluteSymlink), realpathSync(appDir))
assert.equal(brokenResolverDir(relativeSymlink), realpathSync(relativeDir))
assert.equal(fixedResolverDir(relativeSymlink), realpathSync(appDir))
})
test('plugin wrapper patches AppRun before delegating to the real output plugin', async () => {
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-plugin-'))
const appDir = join(root, 'Tolaria.AppDir')
const appRun = join(appDir, 'AppRun')
const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage')
const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage')
const pluginMarker = join(root, 'plugin-ran')
await mkdir(appDir)
await writeFile(
appRun,
[
'#! /usr/bin/env bash',
'# autogenerated by linuxdeploy',
BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE,
'exec "$this_dir"/AppRun.wrapped "$@"',
].join('\n'),
'utf8',
)
await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8')
await chmod(wrapper, 0o755)
await writeFile(
realPlugin,
`#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`,
'utf8',
)
await chmod(realPlugin, 0o755)
const result = spawnSync(wrapper, [], {
encoding: 'utf8',
env: {
...process.env,
APPDIR: appDir,
TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin,
},
})
assert.equal(result.status, 0, result.stderr)
assert.equal(existsSync(pluginMarker), true)
const patched = await readFile(appRun, 'utf8')
assert.equal(patched.includes(BROKEN_LINUXDEPLOY_APPRUN_DIR_LINE), false)
assert.equal(patched.includes(FIXED_LINUXDEPLOY_APPRUN_DIR_LINE), true)
})
test('plugin wrapper keeps the delegated appimage plugin basename canonical', async () => {
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-tools-'))
const { realPluginPath, wrapperPath } = await preparePluginWrapper({
toolsDir: root,
})
assert.equal(basename(wrapperPath), 'linuxdeploy-plugin-appimage.AppImage')
assert.equal(
realPluginPath,
join(root, 'tolaria-real-linuxdeploy-plugin-appimage', 'linuxdeploy-plugin-appimage.AppImage'),
)
assert.equal(REAL_APPIMAGE_PLUGIN_NAME.endsWith('/linuxdeploy-plugin-appimage.AppImage'), true)
const wrapper = await readFile(wrapperPath, 'utf8')
assert.equal(wrapper.includes('linuxdeploy-plugin-appimage.real.AppImage'), false)
})
test('plugin wrapper bundles fcitx GTK3 input module before sealing AppImage', async () => {
const root = await mkdtemp(join(tmpdir(), 'tolaria-appimage-fcitx-'))
const appDir = join(root, 'Tolaria.AppDir')
const wrapper = join(root, 'linuxdeploy-plugin-appimage.AppImage')
const realPlugin = join(root, 'linuxdeploy-plugin-appimage.real.AppImage')
const pluginMarker = join(root, 'plugin-ran')
const hostModule = join(root, 'host', 'im-fcitx5.so')
const hostLibraryDir = join(root, 'host-lib')
const hostLibrary = join(hostLibraryDir, 'libFcitx5GClient.so.2')
await mkdir(appDir)
await mkdir(dirname(hostModule), { recursive: true })
await mkdir(hostLibraryDir)
await writeFile(hostModule, 'fake fcitx gtk module', 'utf8')
await writeFile(hostLibrary, 'fake fcitx client library', 'utf8')
await writeFile(wrapper, appImagePluginWrapperSource(), 'utf8')
await chmod(wrapper, 0o755)
await writeFile(
realPlugin,
`#!/usr/bin/env bash\nset -euo pipefail\ntouch "${pluginMarker}"\n`,
'utf8',
)
await chmod(realPlugin, 0o755)
const result = spawnSync(wrapper, [], {
encoding: 'utf8',
env: {
...process.env,
APPDIR: appDir,
TOLARIA_APPIMAGE_REAL_PLUGIN: realPlugin,
TOLARIA_FCITX_GTK3_IM_MODULE: hostModule,
TOLARIA_FCITX_LIBRARY_DIR: hostLibraryDir,
},
})
assert.equal(result.status, 0, result.stderr)
assert.equal(existsSync(pluginMarker), true)
assert.equal(
existsSync(
join(
appDir,
'usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so',
),
),
true,
)
assert.equal(existsSync(join(appDir, 'usr/lib/x86_64-linux-gnu/libFcitx5GClient.so.2')), true)
})

View File

@@ -384,9 +384,33 @@ pub fn trigger_menu_command(_app_handle: tauri::AppHandle, _id: String) -> Resul
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_current_window_min_size(
window: Window,
fn should_apply_window_min_size_constraints(
is_windows: bool,
is_fullscreen: bool,
is_maximized: bool,
) -> bool {
!(is_windows && (is_fullscreen || is_maximized))
}
#[cfg(desktop)]
fn should_skip_window_min_size_update(window: &Window) -> Result<bool, String> {
if !cfg!(target_os = "windows") {
return Ok(false);
}
let is_fullscreen = window.is_fullscreen().map_err(|e| e.to_string())?;
let is_maximized = window.is_maximized().map_err(|e| e.to_string())?;
Ok(!should_apply_window_min_size_constraints(
true,
is_fullscreen,
is_maximized,
))
}
#[cfg(desktop)]
fn apply_window_min_size_update(
window: &Window,
min_width: f64,
min_height: f64,
grow_to_fit: bool,
@@ -416,6 +440,21 @@ pub fn update_current_window_min_size(
.map_err(|e| e.to_string())
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_current_window_min_size(
window: Window,
min_width: f64,
min_height: f64,
grow_to_fit: bool,
) -> Result<(), String> {
if should_skip_window_min_size_update(&window)? {
return Ok(());
}
apply_window_min_size_update(&window, min_width, min_height, grow_to_fit)
}
#[cfg(desktop)]
#[tauri::command]
pub fn perform_current_window_titlebar_double_click(window: Window) -> Result<(), String> {
@@ -623,6 +662,20 @@ mod tests {
}
}
#[test]
fn skips_min_size_updates_for_windows_fullscreen_or_maximized_windows() {
for (is_fullscreen, is_maximized) in [(true, false), (false, true), (true, true)] {
assert!(!should_apply_window_min_size_constraints(
true,
is_fullscreen,
is_maximized
));
}
assert!(should_apply_window_min_size_constraints(true, false, false));
assert!(should_apply_window_min_size_constraints(false, true, true));
}
#[test]
fn propagates_title_bar_action_errors() {
for (state, maximize, unmaximize, expected) in [

View File

@@ -129,17 +129,17 @@ fn load_configured_active_vault_root() -> Result<Option<VaultRootPaths>, String>
fn load_registered_vault_roots() -> Result<Vec<VaultRootPaths>, String> {
let list = vault_list::load_vault_list()?;
let mut roots = list
.vaults
Ok(registered_vault_roots(&list))
}
fn registered_vault_roots(list: &vault_list::VaultList) -> Vec<VaultRootPaths> {
list.vaults
.iter()
.map(|entry| build_vault_root_paths(&entry.path))
.collect::<Result<Vec<_>, _>>()?;
if let Some(active_vault) = list.active_vault.as_deref() {
if !active_vault.trim().is_empty() {
roots.push(build_vault_root_paths(active_vault)?);
}
}
Ok(roots)
.map(|entry| entry.path.as_str())
.chain(list.active_vault.as_deref())
.filter(|path| !path.trim().is_empty())
.filter_map(|path| build_vault_root_paths(path).ok())
.collect()
}
fn is_registered_vault_root(requested: &VaultRootPaths) -> Result<bool, String> {
@@ -378,3 +378,37 @@ pub(crate) fn with_view_file<T>(
action(&requested_root, filename)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault_list::{VaultEntry, VaultList};
#[test]
fn registered_vault_roots_skip_unavailable_vaults() {
let available = tempfile::TempDir::new().unwrap();
let missing = available.path().join("missing-vault");
let list = VaultList {
vaults: vec![
VaultEntry {
label: "Missing".to_string(),
path: missing.to_string_lossy().to_string(),
..Default::default()
},
VaultEntry {
label: "Available".to_string(),
path: available.path().to_string_lossy().to_string(),
..Default::default()
},
],
active_vault: None,
default_workspace_path: None,
hidden_defaults: vec![],
};
let roots = registered_vault_roots(&list);
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].canonical, available.path().canonicalize().unwrap());
}
}

View File

@@ -250,6 +250,14 @@ fn sync_ws_bridge_for_selected_vault(app_handle: &tauri::AppHandle) {
fn spawn_initial_ws_bridge_sync(app: &tauri::App) {
let app_handle = app.handle().clone();
spawn_background_task("tolaria-ws-bridge-startup", move || {
#[cfg(all(desktop, target_os = "linux"))]
if linux_appimage::is_running() {
let app_version = app_handle.package_info().version.to_string();
if let Err(e) = mcp::extract_mcp_server_to_stable_dir(&app_version) {
log::warn!("Failed to extract MCP server to stable path: {e}");
}
}
sync_ws_bridge_for_selected_vault(&app_handle);
});
}

View File

@@ -4,7 +4,7 @@ struct StartupEnvOverride {
value: &'static str,
}
const LINUX_APPIMAGE_WEBKIT_OVERRIDES: [StartupEnvOverride; 2] = [
const LINUX_WEBKIT_RENDERING_OVERRIDES: [StartupEnvOverride; 2] = [
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
@@ -25,6 +25,10 @@ const FCITX_ENV_HINT_KEYS: [&str; 4] = [
"QT_IM_MODULE",
"SDL_IM_MODULE",
];
const FCITX_GTK3_IM_MODULE_RELATIVE_PATH: &str =
"usr/lib/x86_64-linux-gnu/gtk-3.0/3.0.0/immodules/im-fcitx5.so";
#[cfg(all(desktop, target_os = "linux"))]
const TOLARIA_FCITX_IMMODULES_CACHE_FILE: &str = "tolaria-appimage-fcitx5-immodules.cache";
const COLRV1_EMOJI_FONT_FILE: &str = "Noto-COLRv1.ttf";
#[cfg(all(desktop, target_os = "linux"))]
const TOLARIA_COLRV1_FONTCONFIG_FILE: &str = "tolaria-appimage-no-colrv1-emoji.conf";
@@ -54,6 +58,18 @@ where
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
}
#[cfg(all(desktop, target_os = "linux"))]
pub(crate) fn is_running() -> bool {
is_linux_appimage_launch(|key| std::env::var(key).ok())
}
fn should_disable_unstable_webkit_rendering<F>(get_var: &mut F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
is_linux_appimage_launch(&mut *get_var) || is_wayland_session(&mut *get_var)
}
fn has_non_empty_env<F>(get_var: &mut F, key: &str) -> bool
where
F: FnMut(&str) -> Option<String>,
@@ -101,6 +117,68 @@ where
.any(|key| get_var(key).is_some_and(|value| env_mentions_fcitx(&value)))
}
fn non_empty_env<F>(get_var: &mut F, key: &str) -> Option<String>
where
F: FnMut(&str) -> Option<String>,
{
get_var(key).filter(|value| !value.trim().is_empty())
}
#[derive(Debug, PartialEq, Eq)]
struct FcitxGtkModuleFileOverride {
cache_path: std::path::PathBuf,
cache_contents: String,
}
fn fcitx_immodules_cache_contents(module_path: &std::path::Path) -> String {
format!(
r#"# Tolaria AppImage GTK input method modules
"{}"
"fcitx" "Fcitx 5" "fcitx" "" "ja:ko:zh:*"
"#,
module_path.display()
)
}
fn should_use_bundled_fcitx_gtk_module<F>(get_var: &mut F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
if !is_linux_appimage_launch(&mut *get_var) {
return false;
}
if has_non_empty_env(get_var, "GTK_IM_MODULE_FILE") {
return false;
}
if let Some(gtk_im_module) = non_empty_env(get_var, "GTK_IM_MODULE") {
return env_mentions_fcitx(&gtk_im_module);
}
has_fcitx_env_hint(get_var)
}
fn fcitx_gtk_im_module_file_override_with<F, E>(
get_var: &mut F,
mut module_exists: E,
cache_path: std::path::PathBuf,
) -> Option<FcitxGtkModuleFileOverride>
where
F: FnMut(&str) -> Option<String>,
E: FnMut(&std::path::Path) -> bool,
{
if !should_use_bundled_fcitx_gtk_module(get_var) {
return None;
}
let appdir = non_empty_env(get_var, "APPDIR")?;
let module_path = std::path::Path::new(appdir.trim()).join(FCITX_GTK3_IM_MODULE_RELATIVE_PATH);
module_exists(&module_path).then(|| FcitxGtkModuleFileOverride {
cache_path,
cache_contents: fcitx_immodules_cache_contents(&module_path),
})
}
fn fcitx_gtk_im_module_override_with<F>(get_var: &mut F) -> Option<StartupEnvOverride>
where
F: FnMut(&str) -> Option<String>,
@@ -108,9 +186,6 @@ where
if !is_linux_appimage_launch(&mut *get_var) {
return None;
}
if !is_wayland_session(&mut *get_var) {
return None;
}
if has_env(get_var, "GTK_IM_MODULE") {
return None;
}
@@ -225,11 +300,11 @@ fn startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
where
F: FnMut(&str) -> Option<String>,
{
if !is_linux_appimage_launch(&mut get_var) {
if !should_disable_unstable_webkit_rendering(&mut get_var) {
return Vec::new();
}
let mut overrides: Vec<_> = LINUX_APPIMAGE_WEBKIT_OVERRIDES
let mut overrides: Vec<_> = LINUX_WEBKIT_RENDERING_OVERRIDES
.into_iter()
.filter(|env_override| !has_non_empty_env(&mut get_var, env_override.key))
.collect();
@@ -245,12 +320,43 @@ where
pub(crate) fn apply_startup_env_overrides() {
apply_wayland_client_preload();
apply_colrv1_emoji_font_guard();
apply_fcitx_gtk_im_module_file();
for env_override in startup_env_overrides_with(|key| std::env::var(key).ok()) {
std::env::set_var(env_override.key, env_override.value);
}
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_fcitx_gtk_im_module_file() {
let Some(cache_path) = fcitx_immodules_cache_file_path() else {
eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to resolve cache directory");
return;
};
let Some(env_override) = fcitx_gtk_im_module_file_override_with(
&mut |key| std::env::var(key).ok(),
std::path::Path::is_file,
cache_path,
) else {
return;
};
let Some(parent) = env_override.cache_path.parent() else {
return;
};
if let Err(error) = std::fs::create_dir_all(parent) {
eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to prepare cache ({error})");
return;
}
if let Err(error) = std::fs::write(&env_override.cache_path, env_override.cache_contents) {
eprintln!("Tolaria AppImage fcitx GTK module skipped: failed to write cache ({error})");
return;
}
std::env::set_var("GTK_IM_MODULE_FILE", env_override.cache_path.as_os_str());
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_colrv1_emoji_font_guard() {
let Some(font_path) =
@@ -309,6 +415,18 @@ fn colrv1_fontconfig_file_path() -> Option<std::path::PathBuf> {
)
}
#[cfg(all(desktop, target_os = "linux"))]
fn fcitx_immodules_cache_file_path() -> Option<std::path::PathBuf> {
let cache_dir =
dirs::cache_dir().or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?;
Some(
cache_dir
.join("tolaria")
.join(TOLARIA_FCITX_IMMODULES_CACHE_FILE),
)
}
#[cfg(all(desktop, target_os = "linux"))]
fn launched_appimage_path() -> Result<std::path::PathBuf, String> {
if let Some(appimage) = std::env::var_os("APPIMAGE").filter(|value| !value.is_empty()) {
@@ -366,7 +484,8 @@ fn apply_wayland_client_preload() {
mod tests {
use super::{
colrv1_emoji_font_path_with, colrv1_emoji_fontconfig_contents, elf_library_matches_process,
startup_env_overrides_with, wayland_client_preload_path_with, StartupEnvOverride,
fcitx_gtk_im_module_file_override_with, startup_env_overrides_with,
wayland_client_preload_path_with, StartupEnvOverride, FCITX_GTK3_IM_MODULE_RELATIVE_PATH,
};
fn default_webkit_overrides() -> Vec<StartupEnvOverride> {
@@ -402,14 +521,14 @@ mod tests {
}
#[test]
fn startup_env_overrides_are_empty_outside_appimage_launches() {
fn startup_env_overrides_are_empty_outside_appimage_or_wayland_launches() {
let overrides = startup_env_overrides_with(|_| None);
assert!(overrides.is_empty());
}
#[test]
fn startup_env_overrides_disable_unstable_webkit_rendering_for_appimages() {
fn startup_env_overrides_disable_unstable_webkit_rendering_for_appimage_launches() {
let overrides = startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
_ => None,
@@ -418,6 +537,16 @@ mod tests {
assert_eq!(overrides, default_webkit_overrides());
}
#[test]
fn startup_env_overrides_disable_unstable_webkit_rendering_for_native_wayland_launches() {
let overrides = startup_env_overrides_with(|key| match key {
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
_ => None,
});
assert_eq!(overrides, default_webkit_overrides());
}
#[test]
fn startup_env_overrides_preserve_explicit_user_setting_per_variable() {
let overrides = startup_env_overrides_with(|key| match key {
@@ -442,6 +571,18 @@ mod tests {
assert_eq!(overrides, default_webkit_and_fcitx_overrides());
}
#[test]
fn startup_env_overrides_enable_fcitx_gtk_module_for_x11_appimage() {
let overrides = startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("x11".to_string()),
"XMODIFIERS" => Some("@im=fcitx".to_string()),
_ => None,
});
assert_eq!(overrides, default_webkit_and_fcitx_overrides());
}
#[test]
fn startup_env_overrides_preserve_explicit_gtk_im_module() {
let overrides =
@@ -461,6 +602,50 @@ mod tests {
assert_eq!(overrides, default_webkit_overrides());
}
#[test]
fn fcitx_module_file_override_points_gtk_to_bundled_appimage_module() {
let appdir = std::path::Path::new("/tmp/.mount_Tolaria");
let module_path = appdir.join(FCITX_GTK3_IM_MODULE_RELATIVE_PATH);
let cache_path = std::path::PathBuf::from("/tmp/tolaria/immodules.cache");
let env_override = fcitx_gtk_im_module_file_override_with(
&mut |key| match key {
"APPDIR" => Some(appdir.display().to_string()),
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"GTK_IM_MODULE" => Some("fcitx".to_string()),
"XDG_SESSION_TYPE" => Some("x11".to_string()),
_ => None,
},
|path| path == module_path,
cache_path.clone(),
)
.unwrap();
assert_eq!(env_override.cache_path, cache_path);
assert!(env_override
.cache_contents
.contains(&module_path.display().to_string()));
assert!(env_override
.cache_contents
.contains("\"fcitx\" \"Fcitx 5\""));
}
#[test]
fn fcitx_module_file_override_preserves_explicit_module_cache() {
let env_override = fcitx_gtk_im_module_file_override_with(
&mut |key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"GTK_IM_MODULE" => Some("fcitx".to_string()),
"GTK_IM_MODULE_FILE" => Some("/tmp/custom-immodules.cache".to_string()),
_ => None,
},
|_| true,
std::path::PathBuf::from("/tmp/tolaria/immodules.cache"),
);
assert_eq!(env_override, None);
}
#[test]
fn colrv1_font_guard_targets_reported_appimage_emoji_font() {
let font_path = colrv1_emoji_font_path_with(

View File

@@ -2,7 +2,14 @@ use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
#[cfg(any(test, all(desktop, target_os = "linux")))]
mod extraction;
mod opencode;
mod paths;
mod subprocess;
#[cfg(all(desktop, target_os = "linux"))]
pub(crate) use extraction::extract_mcp_server_to_stable_dir;
const MCP_SERVER_NAME: &str = "tolaria";
const LEGACY_MCP_SERVER_NAME: &str = "laputa";
@@ -76,7 +83,7 @@ fn user_shell_candidates() -> Vec<PathBuf> {
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
subprocess::command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
@@ -104,7 +111,7 @@ fn first_existing_path(stdout: &str) -> Option<PathBuf> {
}
fn verify_node_version(node: &Path) -> Result<(), String> {
let output = crate::hidden_command(node)
let output = subprocess::command(node)
.arg("--version")
.output()
.map_err(|e| format!("Failed to run {} --version: {e}", node.display()))?;
@@ -143,9 +150,9 @@ fn node_major_version(version: &str) -> Option<u32> {
fn node_lookup_command() -> Command {
#[cfg(windows)]
let mut command = crate::hidden_command("where.exe");
let mut command = subprocess::command("where.exe");
#[cfg(not(windows))]
let mut command = crate::hidden_command("which");
let mut command = subprocess::command("which");
command.arg("node");
command
@@ -248,6 +255,15 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
))
}
fn mcp_server_dir_for_registration() -> Result<PathBuf, String> {
#[cfg(all(desktop, target_os = "linux"))]
if let Some(stable_dir) = extraction::ready_stable_mcp_server_dir() {
return Ok(stable_dir);
}
mcp_server_dir()
}
fn build_time_dev_mcp_server_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
@@ -397,7 +413,8 @@ pub fn spawn_ws_bridge_with_paths(
let vault_path = vault_path.as_ref();
let active_vault_paths = active_vault_paths_json(vault_path, vault_paths);
let child = crate::hidden_command(node)
let mut command = subprocess::command(node);
let child = command
.arg(&script)
.env("VAULT_PATH", vault_path)
.env("VAULT_PATHS", active_vault_paths)
@@ -480,29 +497,13 @@ fn entry_has_ui_port(entry: &serde_json::Value) -> bool {
entry["env"]["WS_UI_PORT"].as_str() == Some("9711")
}
fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
let Some(entry_vault_path) = entry["env"]["VAULT_PATH"].as_str() else {
return false;
};
let Ok(expected) = std::fs::canonicalize(vault_path) else {
return false;
};
let Ok(actual) = std::fs::canonicalize(entry_vault_path) else {
return false;
};
actual == expected
}
/// Build the MCP server entry JSON for a given vault path and index.js path.
fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serde_json::Value {
/// Build the durable external MCP server entry JSON for an index.js path.
fn build_mcp_entry(node_command: &str, index_js: &str) -> serde_json::Value {
serde_json::json!({
"type": "stdio",
"command": node_command,
"args": [index_js],
"env": {
"VAULT_PATH": vault_path,
"WS_UI_PORT": "9711"
}
})
@@ -519,13 +520,14 @@ fn build_mcp_config_snippet(entry: &serde_json::Value) -> Result<String, String>
/// Build the exact MCP config JSON users can copy into compatible tools.
pub fn mcp_config_snippet(vault_path: &str) -> Result<String, String> {
let _ = vault_path;
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can build MCP config: {e}")
})?;
let server_dir = mcp_server_dir()?;
let server_dir = mcp_server_dir_for_registration()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
let entry = build_mcp_entry(&node_command, &index_js);
build_mcp_config_snippet(&entry)
}
@@ -546,14 +548,21 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
/// Register Tolaria as an MCP server in external AI tool config files.
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let _ = vault_path;
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can register MCP tools: {e}")
})?;
let server_dir = mcp_server_dir()?;
let server_dir = mcp_server_dir_for_registration()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
let entry = build_mcp_entry(&node_command, &index_js);
let opencode_entry = opencode::build_entry(&node_command, &index_js);
if let Some(config_path) = opencode::config_path() {
if let Err(e) = opencode::upsert_config(&config_path, &opencode_entry) {
log::warn!("Failed to update {}: {}", config_path.display(), e);
}
}
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
}
@@ -655,7 +664,19 @@ fn remove_mcp_from_config(config_path: &Path) -> Result<bool, String> {
}
pub fn remove_mcp() -> String {
remove_mcp_from_configs(&mcp_config_paths())
let removed_standard = remove_mcp_from_configs(&mcp_config_paths()) == "removed";
let removed_opencode = opencode::config_path().is_some_and(|config_path| {
opencode::remove_config(&config_path).unwrap_or_else(|e| {
log::warn!("Failed to update {}: {}", config_path.display(), e);
false
})
});
if removed_standard || removed_opencode {
"removed".to_string()
} else {
"already_absent".to_string()
}
}
/// Check whether the MCP server is properly installed and registered.
@@ -664,15 +685,18 @@ pub fn remove_mcp() -> String {
/// an external AI tool config and the referenced index.js file is present.
/// Otherwise returns `NotInstalled`.
pub fn check_mcp_status(vault_path: &str) -> McpStatus {
let active_vault_path = Path::new(vault_path);
if mcp_config_paths().into_iter().any(|config_path| {
let _ = vault_path;
let installed_standard = mcp_config_paths().into_iter().any(|config_path| {
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
entry_uses_stdio(&entry)
&& entry_index_js_exists(&entry)
&& entry_has_ui_port(&entry)
&& entry_targets_vault(&entry, active_vault_path)
entry_uses_stdio(&entry) && entry_index_js_exists(&entry) && entry_has_ui_port(&entry)
})
}) {
});
let installed_opencode = opencode::config_path().is_some_and(|config_path| {
opencode::read_registered_entry(&config_path)
.is_some_and(|entry| opencode::entry_is_installed(&entry))
});
if installed_standard || installed_opencode {
McpStatus::Installed
} else {
McpStatus::NotInstalled
@@ -698,17 +722,17 @@ mod tests {
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
}
fn managed_server(index_js: &str, vault_path: &str) -> serde_json::Value {
fn managed_server(index_js: &str) -> serde_json::Value {
serde_json::json!({
"type": "stdio",
"command": "node",
"args": [index_js],
"env": { "VAULT_PATH": vault_path, "WS_UI_PORT": "9711" }
"env": { "WS_UI_PORT": "9711" }
})
}
fn test_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
build_mcp_entry("node", index_js, vault_path)
fn test_mcp_entry(index_js: &str) -> serde_json::Value {
build_mcp_entry("node", index_js)
}
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
@@ -721,7 +745,6 @@ mod tests {
struct ExpectedMcpServer<'a> {
index_js: &'a str,
vault_path: &'a str,
}
fn assert_registered_tolaria_server(
@@ -730,7 +753,8 @@ mod tests {
) {
let server = &config["mcpServers"][MCP_SERVER_NAME];
assert_eq!(server["args"][0], expected.index_js);
assert_eq!(server["env"]["VAULT_PATH"], expected.vault_path);
assert!(server["env"]["VAULT_PATH"].is_null());
assert_eq!(server["env"]["WS_UI_PORT"], "9711");
}
fn write_index_js(dir: &Path) -> PathBuf {
@@ -741,7 +765,7 @@ mod tests {
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js", "/my/vault");
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js");
assert_eq!(
entry,
serde_json::json!({
@@ -749,7 +773,6 @@ mod tests {
"command": "/usr/local/bin/node",
"args": ["/path/to/index.js"],
"env": {
"VAULT_PATH": "/my/vault",
"WS_UI_PORT": "9711"
}
})
@@ -758,7 +781,7 @@ mod tests {
#[test]
fn build_mcp_config_snippet_wraps_tolaria_server_entry() {
let entry = test_mcp_entry("/path/to/index.js", "/my/vault");
let entry = test_mcp_entry("/path/to/index.js");
let snippet = build_mcp_config_snippet(&entry).unwrap();
let config: serde_json::Value = serde_json::from_str(&snippet).unwrap();
@@ -766,10 +789,7 @@ mod tests {
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/path/to/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/my/vault"
);
assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null());
}
#[test]
@@ -944,7 +964,7 @@ mod tests {
fn upsert_creates_new_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/test/vault");
let entry = test_mcp_entry("/test/index.js");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(!was_update);
@@ -954,7 +974,6 @@ mod tests {
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/test/vault",
},
);
}
@@ -964,18 +983,15 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry1 = test_mcp_entry("/test/index.js", "/vault/v1");
let entry1 = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry1).unwrap();
let entry2 = test_mcp_entry("/test/index.js", "/vault/v2");
let entry2 = test_mcp_entry("/test/index.js");
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
assert!(was_update);
let config = read_config(&config_path);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/vault/v2"
);
assert!(config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"].is_null());
}
#[test]
@@ -994,7 +1010,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(was_update);
@@ -1017,7 +1033,7 @@ mod tests {
)],
);
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry).unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
@@ -1040,7 +1056,7 @@ mod tests {
}),
);
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
@@ -1067,7 +1083,7 @@ mod tests {
}
}),
);
let entry = test_mcp_entry("/gemini/index.js", "/gemini-vault");
let entry = test_mcp_entry("/gemini/index.js");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
@@ -1079,7 +1095,6 @@ mod tests {
&config,
ExpectedMcpServer {
index_js: "/gemini/index.js",
vault_path: "/gemini-vault",
},
);
}
@@ -1088,7 +1103,7 @@ mod tests {
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
upsert_mcp_config(&config_path, &entry).unwrap();
assert!(config_path.exists());
@@ -1098,7 +1113,7 @@ mod tests {
fn register_mcp_to_configs_returns_registered_for_new() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("claude").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "registered");
@@ -1108,7 +1123,7 @@ mod tests {
fn register_mcp_to_configs_returns_updated_for_existing() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
// First call
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
@@ -1157,7 +1172,7 @@ mod tests {
let gemini_cfg = tmp.path().join(".gemini").join("settings.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
register_mcp_to_configs(
&entry,
@@ -1185,7 +1200,6 @@ mod tests {
&config,
ExpectedMcpServer {
index_js: "/test/index.js",
vault_path: "/vault",
},
);
}
@@ -1211,14 +1225,14 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "not valid json{{{{").unwrap();
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let result = upsert_mcp_config(&config_path, &entry);
assert!(result.is_err());
}
#[test]
fn register_mcp_to_configs_handles_empty_list() {
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
// Empty config list — function should return "registered" (no existing)
let status = register_mcp_to_configs(&entry, &[]);
// With empty config list, there were no updates, so status should be "registered"
@@ -1231,19 +1245,13 @@ mod tests {
write_mcp_servers_config(
&config_path,
vec![
(
MCP_SERVER_NAME,
managed_server("/primary/index.js", "/primary"),
),
(
LEGACY_MCP_SERVER_NAME,
managed_server("/legacy/index.js", "/legacy"),
),
(MCP_SERVER_NAME, managed_server("/primary/index.js")),
(LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js")),
],
);
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert_eq!(entry["env"]["VAULT_PATH"], "/primary");
assert_eq!(entry["args"][0], "/primary/index.js");
}
#[test]
@@ -1251,14 +1259,11 @@ mod tests {
let (_tmp, config_path) = temp_config_path("mcp.json");
write_mcp_servers_config(
&config_path,
vec![(
LEGACY_MCP_SERVER_NAME,
managed_server("/legacy/index.js", "/legacy"),
)],
vec![(LEGACY_MCP_SERVER_NAME, managed_server("/legacy/index.js"))],
);
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert_eq!(entry["env"]["VAULT_PATH"], "/legacy");
assert_eq!(entry["args"][0], "/legacy/index.js");
}
#[test]
@@ -1303,7 +1308,7 @@ mod tests {
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "[]").unwrap();
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let result = upsert_mcp_config(&config_path, &entry);
assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object")));
}
@@ -1317,7 +1322,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = test_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js");
let result = upsert_mcp_config(&config_path, &entry);
assert!(
matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object"))
@@ -1362,42 +1367,26 @@ mod tests {
}
#[test]
fn check_mcp_status_returns_installed_for_matching_vault() {
fn durable_entry_is_status_eligible_without_vault_env() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().join("vault");
std::fs::create_dir_all(&vault_path).unwrap();
let index_js = write_index_js(tmp.path());
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": {
"type": "stdio",
"command": "node",
"args": [index_js.to_string_lossy()],
"env": { "VAULT_PATH": vault_path.to_string_lossy() }
"env": { "WS_UI_PORT": "9711" }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert!(entry_targets_vault(&entry, &vault_path));
assert!(entry_uses_stdio(&entry));
assert!(entry_index_js_exists(&entry));
}
#[test]
fn entry_targets_vault_requires_matching_existing_directory() {
let tmp = tempfile::tempdir().unwrap();
let first_vault = tmp.path().join("vault-a");
let second_vault = tmp.path().join("vault-b");
std::fs::create_dir_all(&first_vault).unwrap();
std::fs::create_dir_all(&second_vault).unwrap();
let entry = serde_json::json!({
"env": { "VAULT_PATH": first_vault.to_string_lossy() }
});
assert!(entry_targets_vault(&entry, &first_vault));
assert!(!entry_targets_vault(&entry, &second_vault));
assert!(entry_has_ui_port(&entry));
}
#[test]

View File

@@ -0,0 +1,303 @@
use std::fs;
#[cfg(all(desktop, target_os = "linux"))]
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
#[cfg(all(desktop, target_os = "linux"))]
use std::time::{Duration, Instant, SystemTime};
const VERSION_MARKER_FILE: &str = ".tolaria-version";
#[cfg(all(desktop, target_os = "linux"))]
const LOCK_FILE: &str = "mcp-server.lock";
const STAGING_DIR: &str = "mcp-server.staging";
const BACKUP_DIR: &str = "mcp-server.previous";
#[cfg(all(desktop, target_os = "linux"))]
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(all(desktop, target_os = "linux"))]
const STALE_LOCK_AFTER: Duration = Duration::from_secs(120);
#[cfg(all(desktop, target_os = "linux"))]
pub(super) fn ready_stable_mcp_server_dir() -> Option<PathBuf> {
let stable_dir = stable_mcp_server_dir().ok()?;
stable_mcp_server_dir_is_ready(&stable_dir).then_some(stable_dir)
}
#[cfg(all(desktop, target_os = "linux"))]
pub(crate) fn extract_mcp_server_to_stable_dir(app_version: &str) -> Result<PathBuf, String> {
let source_dir = super::mcp_server_dir()?;
let target_dir = stable_mcp_server_dir()?;
if !needs_extraction(app_version, &target_dir) {
return Ok(target_dir);
}
let _lock = ExtractionLock::acquire(&extraction_lock_path()?)?;
if !needs_extraction(app_version, &target_dir) {
return Ok(target_dir);
}
replace_stable_server_dir(&source_dir, &target_dir, app_version)?;
Ok(target_dir)
}
fn stable_mcp_server_dir() -> Result<PathBuf, String> {
dirs::data_dir()
.map(|data_dir| data_dir.join("tolaria").join("mcp-server"))
.ok_or_else(|| "Unable to resolve data directory for stable MCP server path".to_string())
}
fn stable_mcp_server_dir_is_ready(dir: &Path) -> bool {
mcp_server_dir_has_files(dir) && read_version_marker(dir).is_some()
}
fn mcp_server_dir_has_files(dir: &Path) -> bool {
dir.join("index.js").is_file() && dir.join("ws-bridge.js").is_file()
}
fn needs_extraction(app_version: &str, target_dir: &Path) -> bool {
!mcp_server_dir_has_files(target_dir)
|| read_version_marker(target_dir).as_deref() != Some(app_version)
}
fn read_version_marker(dir: &Path) -> Option<String> {
fs::read_to_string(dir.join(VERSION_MARKER_FILE))
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn write_version_marker(dir: &Path, app_version: &str) -> Result<(), String> {
let marker = dir.join(VERSION_MARKER_FILE);
fs::write(&marker, app_version)
.map_err(|e| format!("Failed to write version marker {}: {e}", marker.display()))
}
#[cfg(all(desktop, target_os = "linux"))]
fn extraction_lock_path() -> Result<PathBuf, String> {
let stable_dir = stable_mcp_server_dir()?;
stable_dir
.parent()
.map(|parent| parent.join(LOCK_FILE))
.ok_or_else(|| {
format!(
"Stable MCP server path has no parent: {}",
stable_dir.display()
)
})
}
fn replace_stable_server_dir(
source_dir: &Path,
target_dir: &Path,
app_version: &str,
) -> Result<(), String> {
let parent = target_dir.parent().ok_or_else(|| {
format!(
"Stable MCP server path has no parent: {}",
target_dir.display()
)
})?;
let staging_dir = parent.join(STAGING_DIR);
let backup_dir = parent.join(BACKUP_DIR);
remove_dir_if_exists(&staging_dir)?;
remove_dir_if_exists(&backup_dir)?;
copy_dir_all(source_dir, &staging_dir)?;
write_version_marker(&staging_dir, app_version)?;
swap_staging_into_place(&staging_dir, target_dir, &backup_dir)?;
Ok(())
}
fn swap_staging_into_place(
staging_dir: &Path,
target_dir: &Path,
backup_dir: &Path,
) -> Result<(), String> {
if target_dir.exists() {
fs::rename(target_dir, backup_dir)
.map_err(|e| format!("Failed to move stable MCP server aside: {e}"))?;
}
if let Err(error) = fs::rename(staging_dir, target_dir) {
if backup_dir.exists() {
let _ = fs::rename(backup_dir, target_dir);
}
return Err(format!("Failed to activate stable MCP server: {error}"));
}
remove_dir_if_exists(backup_dir)
}
fn copy_dir_all(source: &Path, target: &Path) -> Result<(), String> {
fs::create_dir_all(target)
.map_err(|e| format!("Failed to create {}: {e}", target.display()))?;
for entry in fs::read_dir(source).map_err(|e| {
format!(
"Failed to read MCP server directory {}: {e}",
source.display()
)
})? {
let entry = entry.map_err(|e| format!("Failed to read MCP server entry: {e}"))?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
if source_path.is_dir() {
copy_dir_all(&source_path, &target_path)?;
} else {
fs::copy(&source_path, &target_path).map_err(|e| {
format!(
"Failed to copy {} to {}: {e}",
source_path.display(),
target_path.display()
)
})?;
}
}
Ok(())
}
fn remove_dir_if_exists(path: &Path) -> Result<(), String> {
if path.exists() {
fs::remove_dir_all(path)
.map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
}
Ok(())
}
#[cfg(all(desktop, target_os = "linux"))]
struct ExtractionLock {
path: PathBuf,
}
#[cfg(all(desktop, target_os = "linux"))]
impl ExtractionLock {
fn acquire(path: &Path) -> Result<Self, String> {
let started = Instant::now();
loop {
match Self::try_create(path) {
Ok(()) => {
return Ok(Self {
path: path.to_path_buf(),
});
}
Err(error) if lock_is_stale(path) => {
let _ = fs::remove_file(path);
log::warn!("Removed stale MCP extraction lock after error: {error}");
}
Err(error) if started.elapsed() >= LOCK_TIMEOUT => return Err(error),
Err(_) => std::thread::sleep(Duration::from_millis(50)),
}
}
}
fn try_create(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create MCP extraction lock dir: {e}"))?;
}
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| format!("Failed to acquire MCP extraction lock: {e}"))?;
use std::io::Write;
writeln!(file, "{}", std::process::id())
.map_err(|e| format!("Failed to write MCP extraction lock: {e}"))
}
}
#[cfg(all(desktop, target_os = "linux"))]
impl Drop for ExtractionLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
#[cfg(all(desktop, target_os = "linux"))]
fn lock_is_stale(path: &Path) -> bool {
path.metadata()
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
.is_some_and(|age| age >= STALE_LOCK_AFTER)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_server_dir(parent: &Path) -> PathBuf {
let dir = parent.join("server");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("index.js"), "console.log('index');").unwrap();
fs::write(dir.join("ws-bridge.js"), "console.log('bridge');").unwrap();
dir
}
#[test]
fn stable_mcp_server_dir_uses_app_data_dir() {
let expected = dirs::data_dir()
.expect("data dir should exist")
.join("tolaria")
.join("mcp-server");
assert_eq!(stable_mcp_server_dir().unwrap(), expected);
}
#[test]
fn stable_mcp_server_dir_requires_marker_and_files() {
let tmp = tempfile::tempdir().unwrap();
let stable_dir = tmp.path().join("tolaria").join("mcp-server");
fs::create_dir_all(&stable_dir).unwrap();
fs::write(stable_dir.join("index.js"), "").unwrap();
fs::write(stable_dir.join("ws-bridge.js"), "").unwrap();
assert!(!stable_mcp_server_dir_is_ready(&stable_dir));
write_version_marker(&stable_dir, "2026.5.14").unwrap();
assert!(stable_mcp_server_dir_is_ready(&stable_dir));
}
#[test]
fn needs_extraction_tracks_version_marker() {
let target = tempfile::tempdir().unwrap();
fs::write(target.path().join("index.js"), "").unwrap();
fs::write(target.path().join("ws-bridge.js"), "").unwrap();
assert!(needs_extraction("2026.5.14", target.path()));
write_version_marker(target.path(), "2026.5.14").unwrap();
assert!(!needs_extraction("2026.5.14", target.path()));
assert!(needs_extraction("2026.5.15", target.path()));
}
#[test]
fn copy_dir_all_copies_nested_server_files() {
let tmp = tempfile::tempdir().unwrap();
let source = create_server_dir(tmp.path());
fs::create_dir_all(source.join("nested")).unwrap();
fs::write(source.join("nested").join("package.json"), "{}").unwrap();
let target = tmp.path().join("target");
copy_dir_all(&source, &target).unwrap();
assert!(target.join("index.js").is_file());
assert!(target.join("ws-bridge.js").is_file());
assert!(target.join("nested").join("package.json").is_file());
}
#[test]
fn replace_stable_server_dir_swaps_versioned_copy() {
let tmp = tempfile::tempdir().unwrap();
let source = create_server_dir(tmp.path());
let target = tmp.path().join("tolaria").join("mcp-server");
fs::create_dir_all(&target).unwrap();
fs::write(target.join("stale.txt"), "old").unwrap();
replace_stable_server_dir(&source, &target, "2026.5.14").unwrap();
assert!(target.join("index.js").is_file());
assert!(!target.join("stale.txt").exists());
assert_eq!(read_version_marker(&target), Some("2026.5.14".to_string()));
}
}

View File

@@ -0,0 +1,241 @@
use std::path::{Path, PathBuf};
use serde_json::{Map, Value};
use super::{LEGACY_MCP_SERVER_NAME, MCP_SERVER_NAME};
const OPENCODE_MCP_KEY: &str = "mcp";
pub(super) fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|config_dir| config_dir.join("opencode").join("opencode.json"))
}
pub(super) fn build_entry(node_command: &str, index_js: &str) -> Value {
serde_json::json!({
"type": "local",
"command": [node_command, index_js],
"enabled": true,
"environment": {
"WS_UI_PORT": "9711"
}
})
}
pub(super) fn upsert_config(config_path: &Path, entry: &Value) -> Result<bool, String> {
let mut config = read_config_or_empty(config_path)?;
let servers = ensure_servers_object(&mut config)?;
let was_update =
servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some();
servers.remove(LEGACY_MCP_SERVER_NAME);
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
write_config(config_path, &config)?;
Ok(was_update)
}
pub(super) fn remove_config(config_path: &Path) -> Result<bool, String> {
if !config_path.exists() {
return Ok(false);
}
let mut config = read_config_or_empty(config_path)?;
let Some(config_object) = config.as_object_mut() else {
return Err("Config is not a JSON object".into());
};
let Some(servers_value) = config_object.get_mut(OPENCODE_MCP_KEY) else {
return Ok(false);
};
let Some(servers) = servers_value.as_object_mut() else {
return Err("mcp is not a JSON object".into());
};
let removed_primary = servers.remove(MCP_SERVER_NAME).is_some();
let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some();
if !removed_primary && !removed_legacy {
return Ok(false);
}
if servers.is_empty() {
config_object.remove(OPENCODE_MCP_KEY);
}
write_config(config_path, &config)?;
Ok(true)
}
pub(super) fn read_registered_entry(config_path: &Path) -> Option<Value> {
let raw = std::fs::read_to_string(config_path).ok()?;
let config: Value = serde_json::from_str(&raw).ok()?;
config
.get(OPENCODE_MCP_KEY)
.and_then(Value::as_object)
.and_then(|servers| {
servers
.get(MCP_SERVER_NAME)
.or_else(|| servers.get(LEGACY_MCP_SERVER_NAME))
})
.cloned()
}
pub(super) fn entry_is_installed(entry: &Value) -> bool {
entry["type"].as_str() == Some("local")
&& entry["enabled"].as_bool() == Some(true)
&& entry["environment"]["WS_UI_PORT"].as_str() == Some("9711")
&& command_index_js_exists(entry)
}
fn command_index_js_exists(entry: &Value) -> bool {
entry["command"]
.as_array()
.and_then(|command| command.get(1))
.and_then(Value::as_str)
.is_some_and(|index_js| Path::new(index_js).exists())
}
fn read_config_or_empty(config_path: &Path) -> Result<Value, String> {
if !config_path.exists() {
return Ok(serde_json::json!({}));
}
let raw = std::fs::read_to_string(config_path)
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
serde_json::from_str(&raw)
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))
}
fn ensure_servers_object(config: &mut Value) -> Result<&mut Map<String, Value>, String> {
let servers = config
.as_object_mut()
.ok_or("Config is not a JSON object")?
.entry(OPENCODE_MCP_KEY)
.or_insert_with(|| serde_json::json!({}));
servers
.as_object_mut()
.ok_or_else(|| "mcp is not a JSON object".to_string())
}
fn write_config(config_path: &Path, config: &Value) -> Result<(), String> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
let json = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(config_path, json)
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn read_config(config_path: &Path) -> Value {
let raw = std::fs::read_to_string(config_path).unwrap();
serde_json::from_str(&raw).unwrap()
}
fn write_config_json(config_path: &Path, config: Value) {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
}
#[test]
fn build_entry_uses_opencode_schema_without_vault_path() {
let entry = build_entry("node", "/app/mcp-server/index.js");
assert_eq!(
entry,
serde_json::json!({
"type": "local",
"command": ["node", "/app/mcp-server/index.js"],
"enabled": true,
"environment": {
"WS_UI_PORT": "9711"
}
})
);
assert!(entry["environment"]["VAULT_PATH"].is_null());
}
#[test]
fn upsert_config_preserves_other_opencode_settings() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("opencode.json");
write_config_json(
&config_path,
serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"mcp": {
"other": { "type": "local" }
}
}),
);
let was_update = upsert_config(&config_path, &build_entry("node", "/index.js")).unwrap();
let config = read_config(&config_path);
assert!(!was_update);
assert_eq!(config["$schema"], "https://opencode.ai/config.json");
assert!(config["mcp"]["other"].is_object());
assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/index.js");
}
#[test]
fn upsert_config_migrates_legacy_server_name() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("opencode.json");
write_config_json(
&config_path,
serde_json::json!({
"mcp": {
"laputa": { "type": "local", "command": ["node", "/old.js"] }
}
}),
);
let was_update = upsert_config(&config_path, &build_entry("node", "/new.js")).unwrap();
let config = read_config(&config_path);
assert!(was_update);
assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null());
assert_eq!(config["mcp"][MCP_SERVER_NAME]["command"][1], "/new.js");
}
#[test]
fn remove_config_removes_primary_and_legacy_entries() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("opencode.json");
write_config_json(
&config_path,
serde_json::json!({
"mcp": {
"tolaria": { "type": "local" },
"laputa": { "type": "local" },
"other": { "type": "local" }
}
}),
);
assert!(remove_config(&config_path).unwrap());
let config = read_config(&config_path);
assert!(config["mcp"][MCP_SERVER_NAME].is_null());
assert!(config["mcp"][LEGACY_MCP_SERVER_NAME].is_null());
assert!(config["mcp"]["other"].is_object());
}
#[test]
fn entry_is_installed_checks_opencode_shape_and_index_path() {
let tmp = tempfile::tempdir().unwrap();
let index_js = tmp.path().join("index.js");
std::fs::write(&index_js, "").unwrap();
let entry = build_entry("node", &index_js.to_string_lossy());
assert!(entry_is_installed(&entry));
let missing = build_entry("node", &tmp.path().join("missing.js").to_string_lossy());
assert!(!entry_is_installed(&missing));
}
}

View File

@@ -0,0 +1,78 @@
use std::ffi::OsStr;
use std::process::Command;
#[cfg(any(test, all(desktop, target_os = "linux")))]
const APPIMAGE_ENV_REMOVALS: [&str; 3] = ["LD_LIBRARY_PATH", "LD_PRELOAD", "GIT_EXEC_PATH"];
pub(super) fn command(program: impl AsRef<OsStr>) -> Command {
let mut command = crate::hidden_command(program);
sanitize_appimage_env(&mut command);
command
}
#[cfg(all(desktop, target_os = "linux"))]
fn sanitize_appimage_env(command: &mut Command) {
sanitize_appimage_env_for_launch(command, appimage_env_present());
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn sanitize_appimage_env(_command: &mut Command) {}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn sanitize_appimage_env_for_launch(command: &mut Command, is_appimage: bool) {
if !is_appimage {
return;
}
for key in APPIMAGE_ENV_REMOVALS {
command.env_remove(key);
}
}
#[cfg(all(desktop, target_os = "linux"))]
fn appimage_env_present() -> bool {
["APPIMAGE", "APPDIR"]
.into_iter()
.any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
fn command_envs(command: &Command) -> std::collections::HashMap<String, Option<String>> {
command
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().to_string(),
value.map(|entry| entry.to_string_lossy().to_string()),
)
})
.collect()
}
#[test]
fn appimage_mcp_subprocesses_remove_loader_env() {
let mut command = crate::hidden_command("node");
sanitize_appimage_env_for_launch(&mut command, true);
let envs = command_envs(&command);
for key in APPIMAGE_ENV_REMOVALS {
assert_eq!(envs.get(key), Some(&None));
}
}
#[test]
fn non_appimage_mcp_subprocesses_keep_parent_env_unmodified() {
let mut command = crate::hidden_command("node");
sanitize_appimage_env_for_launch(&mut command, false);
let envs = command_envs(&command);
for key in APPIMAGE_ENV_REMOVALS {
assert!(!envs.contains_key(key));
}
}
}

View File

@@ -2,6 +2,7 @@
use crate::window_state::MAIN_WINDOW_LABEL;
use serde::{Deserialize, Deserializer};
use std::{
borrow::Cow,
collections::{BTreeMap, HashSet},
error::Error,
sync::OnceLock,
@@ -253,6 +254,14 @@ fn native_window_menu_submenu_id(target_os: &str) -> Option<&'static str> {
}
}
fn native_menu_label(label: &str) -> Cow<'_, str> {
if label.contains('&') {
Cow::Owned(label.replace('&', "&&"))
} else {
Cow::Borrowed(label)
}
}
fn build_manifest_menu_item(
app: &App,
item: &ManifestMenuItem,
@@ -263,8 +272,11 @@ fn build_manifest_menu_item(
let Some(label) = item.label(std::env::consts::OS) else {
return Ok(None);
};
let label = native_menu_label(label);
let mut builder = MenuItemBuilder::new(label).id(id).enabled(item.enabled());
let mut builder = MenuItemBuilder::new(label.as_ref())
.id(id)
.enabled(item.enabled());
if let Some(accelerator) = item.accelerator(manifest()) {
builder = builder.accelerator(accelerator);
}
@@ -641,4 +653,28 @@ mod tests {
assert_eq!(native_window_menu_submenu_id("windows"), None);
assert_eq!(native_window_menu_submenu_id("linux"), None);
}
#[test]
fn native_menu_labels_escape_literal_ampersands() {
assert_eq!(native_menu_label("Commit & Push"), "Commit && Push");
assert_eq!(
native_menu_label("Research && Development"),
"Research &&&& Development"
);
}
#[test]
fn native_menu_labels_without_ampersands_are_unchanged() {
assert_eq!(native_menu_label("Pull from Remote"), "Pull from Remote");
}
#[test]
fn vault_commit_push_menu_label_is_native_menu_safe() {
let item = menu_item_by_id("vault-commit-push");
let label = item.label("windows").expect("commit push label exists");
assert_eq!(label, "Commit & Push");
assert_eq!(native_menu_label(label), "Commit && Push");
assert_eq!(item.accelerator(manifest()), None);
}
}

View File

@@ -219,6 +219,9 @@ mod tests {
use super::*;
use std::ffi::OsStr;
use std::path::PathBuf;
use std::sync::Mutex;
static PI_AGENT_ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
key: &'static str,
@@ -270,7 +273,10 @@ mod tests {
#[test]
fn command_sets_vault_cwd_closed_stdin_and_config_dir() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
let source_agent_dir = tempfile::tempdir().unwrap();
let agent_dir = tempfile::tempdir().unwrap();
let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path());
let command = build_command(&PathBuf::from("pi"), &request(), agent_dir.path()).unwrap();
let actual_args: Vec<&OsStr> = command.get_args().collect();
let config_dir = command
@@ -301,6 +307,7 @@ mod tests {
#[test]
fn command_seeds_temp_agent_dir_from_existing_pi_config() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
let source_agent_dir = tempfile::tempdir().unwrap();
let agent_dir = tempfile::tempdir().unwrap();
write_existing_pi_config(source_agent_dir.path());

View File

@@ -132,7 +132,13 @@ const relatedEntry = makeEntry({
title: 'Software Development',
isA: 'Topic',
})
const entries = [activeEntry, relatedEntry]
const secondEntry = makeEntry({
path: '/vault/project/second.md',
filename: 'second.md',
title: 'Second Project',
isA: 'Project',
})
const entries = [activeEntry, relatedEntry, secondEntry]
const noteContent = '---\ntitle: Test Project\ntype: Project\n---\n\n# Test Project\n'
const defaultVaultPath = DEFAULT_VAULTS[0].path || '/Users/mock/Documents/Getting Started'
@@ -215,7 +221,7 @@ describe('App note windows', () => {
)
})
it('passes the loaded vault index to the editor in note windows', async () => {
it('keeps note windows scoped to the active entry without loading the vault index', async () => {
renderApp(<App />)
await waitFor(() => {
@@ -224,18 +230,44 @@ describe('App note windows', () => {
vaultPath: '/vault',
})
})
await waitFor(() => {
expect(commandResults.list_vault).toHaveBeenCalledWith({ path: '/vault' })
})
expect(commandResults.list_vault).not.toHaveBeenCalled()
await waitFor(() => {
expect(screen.getByTestId('mock-editor-entry-titles')).toHaveTextContent(
'Test Project|Software Development',
'Test Project',
)
})
expect(editorSnapshots.at(-1)).toEqual({
activeTabPath: activeEntry.path,
entryTitles: ['Test Project', 'Software Development'],
entryTitles: ['Test Project'],
})
})
it('opens repeated note windows without repeated full-vault scans', async () => {
const firstWindow = renderApp(<App />)
await waitFor(() => {
expect(commandResults.reload_vault_entry).toHaveBeenCalledWith({
path: activeEntry.path,
vaultPath: '/vault',
})
})
firstWindow.unmount()
window.history.replaceState(
{},
'',
'/?window=note&path=%2Fvault%2Fproject%2Fsecond.md&vault=%2Fvault&title=Second+Project',
)
renderApp(<App />)
await waitFor(() => {
expect(commandResults.reload_vault_entry).toHaveBeenCalledWith({
path: secondEntry.path,
vaultPath: '/vault',
})
})
expect(commandResults.reload_vault_entry).toHaveBeenCalledTimes(2)
expect(commandResults.list_vault).not.toHaveBeenCalled()
})
})

View File

@@ -576,7 +576,7 @@ describe('App', () => {
})
})
it('opens a note window by loading the requested entry and vault index', async () => {
it('opens a note window by loading only the requested entry', async () => {
const listVault = vi.fn(() => mockEntries)
const reloadVaultEntry = vi.fn(({ path }: { path: string }) =>
mockEntries.find((entry) => entry.path === path) ?? null,
@@ -599,7 +599,7 @@ describe('App', () => {
expect(getNoteContent).toHaveBeenCalledWith({ path: '/vault/project/test.md', vaultPath: '/vault' })
await waitFor(() => expect(window.__laputaTest?.activeTabPath).toBe('/vault/project/test.md'))
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
expect(listVault).toHaveBeenCalledWith({ path: '/vault' })
expect(listVault).not.toHaveBeenCalled()
})
it('shows keyboard shortcut hints', async () => {
@@ -630,9 +630,7 @@ describe('App', () => {
it('persists a Cmd+N note before opening it in the editor', async () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
let resolveSave!: () => void
const saveNoteContent = vi.fn(() => new Promise<void>((resolve) => {
resolveSave = resolve
}))
const saveNoteContent = vi.fn(() => new Promise<void>((resolve) => { resolveSave = resolve }))
mockCommandResults.save_note_content = saveNoteContent
try {
@@ -645,6 +643,7 @@ describe('App', () => {
expect(saveNoteContent).toHaveBeenCalledWith({
path: '/vault/untitled-note-1700000000.md',
content: '---\ntype: Note\n---\n\n# \n\n',
vaultPath: '/vault',
})
})
expect(window.__laputaTest?.activeTabPath).not.toBe('/vault/untitled-note-1700000000.md')

View File

@@ -86,7 +86,7 @@ import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { isWindows } from './utils/platform'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { getPulledVaultUpdateOptions, refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
import { GitSetupDialog } from './components/GitRequiredModal'
import { RenameDetectedBanner } from './components/RenameDetectedBanner'
@@ -368,7 +368,7 @@ function App() {
})
const vault = useVaultLoader(
resolvedPath,
noteWindowParams ? '' : resolvedPath,
graphVaults,
multiWorkspaceEnabled ? defaultWorkspacePath : null,
folderVaults,
@@ -652,7 +652,7 @@ function App() {
vault.unsavedPaths,
])
const handlePulledVaultUpdate = useCallback(
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles),
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles, getPulledVaultUpdateOptions()),
[handleVaultUpdate],
)
const handleFocusedVaultUpdate = useCallback(
@@ -968,6 +968,7 @@ function App() {
setToastMessage,
onPushRejected: autoSync.handlePushRejected,
automaticVaultPaths: activeGitRepositoryPaths,
locale: appLocale,
manualVaultPath: gitSurfaces.commitRepositoryPath,
vaultPath: resolvedPath,
})
@@ -1686,7 +1687,7 @@ function App() {
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
isVaultLoading={isVaultContentLoading}
entries={visibleEntries}
entries={noteWindowParams && activeTab ? [activeTab.entry] : visibleEntries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={loadDiffForPath}
onLoadDiffAtCommit={loadDiffAtCommitForPath}
@@ -1757,7 +1758,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
@@ -1807,7 +1808,7 @@ function App() {
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={isGitVault} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={isGitVault} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} locale={appLocale} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />

View File

@@ -1,10 +1,10 @@
import { CalendarBlank as CalendarIcon, Check, X } from '@phosphor-icons/react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, Check, X } from 'lucide-react'
import {
type PropertyDisplayMode,
formatDateValue,

View File

@@ -1,4 +1,4 @@
import { ArrowUpRight, Bot, CheckCircle2, Cloud, HardDrive, Loader2, Terminal } from 'lucide-react'
import { ArrowUpRight, CheckCircle as CheckCircle2, CircleNotch as Loader2, Cloud, HardDrive, Robot as Bot, Terminal } from '@phosphor-icons/react'
import {
AI_AGENT_DEFINITIONS,
getAiAgentDefinition,

View File

@@ -1,4 +1,4 @@
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
import { ArrowUpRight, CheckCircle as CheckCircle2, CircleNotch as Loader2, Robot as Bot } from '@phosphor-icons/react'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import { openExternalUrl } from '../utils/url'
import { Button } from './ui/button'

View File

@@ -1,4 +1,4 @@
import { AlertTriangle } from 'lucide-react'
import { Warning as AlertTriangle } from '@phosphor-icons/react'
import { translate, type AppLocale } from '../lib/i18n'
interface ConflictNoteBannerProps {

View File

@@ -1,7 +1,7 @@
import { Check, CircleNotch as Loader2, FileText, Warning as AlertTriangle } from '@phosphor-icons/react'
import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Check, Loader2 } from 'lucide-react'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { ConflictFileState } from '../hooks/useConflictResolver'
import { cn } from '@/lib/utils'

View File

@@ -1,5 +1,5 @@
import { CircleNotch as Loader2 } from '@phosphor-icons/react'
import { memo } from 'react'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DeleteProgressNoticeProps {

View File

@@ -1,6 +1,5 @@
import { ArrowUpRight, Bug, Chats as MessagesSquare, Check, Copy, GitPullRequest, Lightbulb, Megaphone, Newspaper } from '@phosphor-icons/react'
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Megaphone } from '@phosphor-icons/react'
import { ArrowUpRight, Bug, Check, Copy, GitPullRequest, Lightbulb, MessagesSquare, Newspaper } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,

View File

@@ -1,5 +1,5 @@
import { GitBranch } from '@phosphor-icons/react'
import { useState } from 'react'
import { GitBranch } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,

View File

@@ -9,7 +9,6 @@ const MANUAL_CONFIG = JSON.stringify({
command: 'node',
args: ['/Applications/Tolaria.app/Contents/Resources/mcp-server/index.js'],
env: {
VAULT_PATH: '/Users/luca/Laputa',
WS_UI_PORT: '9711',
},
},
@@ -34,7 +33,7 @@ describe('McpSetupDialog', () => {
expect(screen.getByText(/will not touch third-party config files until you confirm here/i)).toBeInTheDocument()
expect(screen.getByText(/requires Node.js 18\+ on PATH/i)).toBeInTheDocument()
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"type": "stdio"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"VAULT_PATH": "/Users/luca/Laputa"')
expect(screen.getByTestId('mcp-config-snippet')).not.toHaveTextContent('"VAULT_PATH"')
expect(screen.getByTestId('mcp-config-snippet')).toHaveTextContent('"WS_UI_PORT": "9711"')
expect(screen.getByText('~/.claude.json')).toBeInTheDocument()
expect(screen.getByText('~/.claude/mcp.json')).toBeInTheDocument()

View File

@@ -1,5 +1,5 @@
import { Copy, ShieldCheck } from '@phosphor-icons/react'
import { useEffect } from 'react'
import { Copy, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,

View File

@@ -31,9 +31,14 @@ describe('MermaidDiagram', () => {
await waitFor(() => {
expect(screen.getByTestId('mermaid-diagram-viewport').querySelector('svg')).not.toBeNull()
})
expect(mermaidMock.render).toHaveBeenCalledWith(expect.stringMatching(/^tolaria-mermaid-/), 'flowchart LR\nA --> B')
expect(mermaidMock.render).toHaveBeenCalledWith(
expect.stringMatching(/^tolaria-mermaid-/),
'flowchart LR\nA --> B',
expect.any(HTMLElement),
)
expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({
htmlLabels: false,
suppressErrorRendering: true,
theme: 'default',
}))
})
@@ -115,4 +120,27 @@ describe('MermaidDiagram', () => {
expect(await screen.findByText('Mermaid diagram unavailable')).toBeInTheDocument()
expect(screen.getByLabelText('Mermaid source')).toHaveTextContent('flowchart LR')
})
it('removes Mermaid error-rendering artifacts after a parse failure', async () => {
let leakedId = ''
mermaidMock.render.mockImplementationOnce(async (renderId: string, _diagram: string, container?: HTMLElement) => {
leakedId = `d${renderId}`
const leakedErrorSvgHost = document.createElement('div')
leakedErrorSvgHost.id = leakedId
leakedErrorSvgHost.textContent = 'Syntax error in text'
const leakTarget = container ?? document.body
leakTarget.appendChild(leakedErrorSvgHost)
throw new Error('parse error')
})
render(
<MermaidDiagram
diagram={'flowchart TD\n## ABC'}
source={'```mermaid\nflowchart TD\n## ABC\n```'}
/>,
)
expect(await screen.findByText('Mermaid diagram unavailable')).toBeInTheDocument()
expect(document.getElementById(leakedId)).toBeNull()
})
})

View File

@@ -1,5 +1,5 @@
import { ArrowsOut as Maximize2 } from '@phosphor-icons/react'
import { useEffect, useId, useMemo, useState } from 'react'
import { Maximize2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -33,6 +33,15 @@ interface RenderState {
let initialized = false
let renderQueue = Promise.resolve()
const MERMAID_RENDER_HOST_STYLE = [
'position:absolute',
'left:-10000px',
'top:-10000px',
'width:0',
'height:0',
'overflow:hidden',
].join(';')
function renderIdFromReactId(reactId: string): string {
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '')
return `tolaria-mermaid-${safeId || 'diagram'}`
@@ -46,6 +55,7 @@ function initializeMermaid(mermaid: MermaidApi) {
securityLevel: 'strict',
htmlLabels: false,
theme: 'default',
suppressErrorRendering: true,
themeVariables: {
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
},
@@ -53,6 +63,21 @@ function initializeMermaid(mermaid: MermaidApi) {
initialized = true
}
function appendMermaidRenderHost(): HTMLDivElement {
const host = document.createElement('div')
host.setAttribute('data-tolaria-mermaid-render-host', '')
host.style.cssText = MERMAID_RENDER_HOST_STYLE
document.body.appendChild(host)
return host
}
function removeMermaidRenderArtifacts(renderId: string, host: HTMLElement): void {
host.remove()
document.getElementById(renderId)?.remove()
document.getElementById(`d${renderId}`)?.remove()
document.getElementById(`i${renderId}`)?.remove()
}
async function renderMermaidDiagram({
diagram,
renderId,
@@ -63,8 +88,13 @@ async function renderMermaidDiagram({
const render = async () => {
const mermaid = (await import('mermaid')).default
initializeMermaid(mermaid)
const result = await mermaid.render(renderId, diagram)
return result.svg
const renderHost = appendMermaidRenderHost()
try {
const result = await mermaid.render(renderId, diagram, renderHost)
return result.svg
} finally {
removeMermaidRenderArtifacts(renderId, renderHost)
}
}
const nextRender = renderQueue.then(render, render)
renderQueue = nextRender.then(() => undefined, () => undefined)

View File

@@ -1,17 +1,14 @@
import { ArrowUpRight, X as XIcon } from '@phosphor-icons/react'
import { useState, useCallback, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { ArrowUpRight } from '@phosphor-icons/react'
import type { FrontmatterValue } from './Inspector'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { XIcon } from 'lucide-react'
import { trackDatePropertyDirectEntrySaved } from '../lib/productAnalytics'
import { translate, type AppLocale } from '../lib/i18n'
import { isValidCssColor } from '../utils/colorUtils'
import { isUrlValue } from '../utils/url'
import {
type PropertyDisplayMode,
formatDateValue,
@@ -20,6 +17,8 @@ import {
DISPLAY_MODE_ICONS,
} from '../utils/propertyTypes'
import { StatusDropdown } from './StatusDropdown'
import type { FrontmatterValue } from './Inspector'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { getStatusStyle } from '../utils/statusStyles'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'

View File

@@ -1,5 +1,5 @@
import { CaretDown as ChevronDown, CaretRight as ChevronRight, CaretUp as ChevronUp, X } from '@phosphor-icons/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight, ChevronUp, X } from 'lucide-react'
import { EditorView } from '@codemirror/view'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'

View File

@@ -1,4 +1,5 @@
import { Bot, Folder, GitBranch, ListChecks, Palette, RefreshCw, ShieldCheck } from 'lucide-react'
import { Article, ArrowsClockwise as RefreshCw, Cube, GitBranch, ListChecks, Palette, Robot as Bot, ShieldCheck, type IconProps } from '@phosphor-icons/react'
import type { ComponentType } from 'react'
import type { TranslationKey } from '../lib/i18n'
import { Button } from './ui/button'
import { SETTINGS_SECTION_IDS } from './settingsSectionIds'
@@ -7,13 +8,19 @@ interface SettingsBodyNavProps {
t: (key: TranslationKey) => string
}
interface SettingsNavItem {
id: string
label: string
Icon: ComponentType<IconProps>
}
export function SettingsBodyNav({ t }: SettingsBodyNavProps) {
const items = [
const items: SettingsNavItem[] = [
{ id: SETTINGS_SECTION_IDS.sync, label: t('settings.sync.title'), Icon: RefreshCw },
{ id: SETTINGS_SECTION_IDS.workspaces, label: t('settings.workspaces.title'), Icon: Folder },
{ id: SETTINGS_SECTION_IDS.workspaces, label: t('settings.workspaces.title'), Icon: Cube },
{ id: SETTINGS_SECTION_IDS.autogit, label: t('settings.autogit.title'), Icon: GitBranch },
{ id: SETTINGS_SECTION_IDS.appearance, label: t('settings.appearance.title'), Icon: Palette },
{ id: SETTINGS_SECTION_IDS.content, label: t('settings.vaultContent.title'), Icon: Folder },
{ id: SETTINGS_SECTION_IDS.content, label: t('settings.vaultContent.title'), Icon: Article },
{ id: SETTINGS_SECTION_IDS.ai, label: t('settings.aiAgents.title'), Icon: Bot },
{ id: SETTINGS_SECTION_IDS.workflow, label: t('settings.workflow.title'), Icon: ListChecks },
{ id: SETTINGS_SECTION_IDS.privacy, label: t('settings.privacy.title'), Icon: ShieldCheck },
@@ -31,7 +38,7 @@ export function SettingsBodyNav({ t }: SettingsBodyNavProps) {
className="h-10 w-full justify-start gap-2.5 px-2.5 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={() => document.getElementById(item.id)?.scrollIntoView({ block: 'start', behavior: 'smooth' })}
>
<item.Icon size={16} className="shrink-0" />
<item.Icon size={16} weight="regular" className="shrink-0" />
<span className="truncate">{item.label}</span>
</Button>
))}

View File

@@ -35,13 +35,16 @@ export function SettingsSection({
}
export function SectionHeading({
icon,
title,
}: {
icon?: ReactNode
title: string
description?: string
}) {
return (
<div>
<div className="flex items-center gap-2">
{icon ? <span className="flex size-5 items-center justify-center text-muted-foreground">{icon}</span> : null}
<div
style={{
fontSize: 14,

View File

@@ -235,6 +235,7 @@ describe('SettingsPanel', () => {
it('keeps vault identity management hidden until multiple vaults are enabled', () => {
const onUpdateWorkspaceIdentity = vi.fn()
const onReorderVaults = vi.fn()
render(
<SettingsPanel
open={true}
@@ -242,6 +243,7 @@ describe('SettingsPanel', () => {
vaults={workspaceVaults}
defaultWorkspacePath="/personal"
onSave={onSave}
onReorderVaults={onReorderVaults}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onClose={onClose}
/>,
@@ -264,6 +266,9 @@ describe('SettingsPanel', () => {
expect(screen.getAllByLabelText('The stable prefix used in cross-vault links and relationships. It is read-only for now to avoid breaking existing references.').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'Make Default' })).toHaveAttribute('data-variant', 'secondary')
expect(within(screen.getByTestId('settings-workspace-row-personal')).getByRole('button', { name: 'Purple' }).getAttribute('style')).toContain('2px solid var(--foreground)')
expect(screen.getByRole('button', { name: 'Move vault Personal Notes up' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Move vault Personal Notes down' }))
expect(onReorderVaults).toHaveBeenCalledWith(['/team', '/personal'])
const nameInput = screen.getByLabelText('Vault name for Personal Notes')
fireEvent.change(nameInput, {

View File

@@ -1,3 +1,4 @@
import { Copy, Cube, Monitor, Moon, Sun, X } from '@phosphor-icons/react'
import {
AI_AGENT_DEFINITIONS,
createMissingAiAgentsStatus,
@@ -14,16 +15,14 @@ import {
type AiModelProvider,
} from '../lib/aiTargets'
import {
useState,
useRef,
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from 'react'
import { Moon, Sun, X } from '@phosphor-icons/react'
import { Copy, Monitor } from 'lucide-react'
import type { Settings } from '../types'
import {
APP_LOCALES,
@@ -94,9 +93,7 @@ interface SettingsPanelProps {
onCopyMcpConfig?: () => void
vaults?: VaultOption[]
defaultWorkspacePath?: string | null
onRemoveVault?: (path: string) => void
onSetDefaultWorkspace?: (path: string) => void
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
onRemoveVault?: (path: string) => void; onReorderVaults?: (orderedPaths: string[]) => void; onSetDefaultWorkspace?: (path: string) => void; onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
isGitVault?: boolean
explicitOrganizationEnabled?: boolean
onSaveExplicitOrganization?: (enabled: boolean) => void
@@ -175,9 +172,7 @@ interface SettingsBodyProps {
setMultiWorkspaceEnabled: (value: boolean) => void
vaults: VaultOption[]
defaultWorkspacePath?: string | null
onRemoveVault?: (path: string) => void
onSetDefaultWorkspace?: (path: string) => void
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
onRemoveVault?: (path: string) => void; onReorderVaults?: (orderedPaths: string[]) => void; onSetDefaultWorkspace?: (path: string) => void; onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
explicitOrganization: boolean
setExplicitOrganization: (value: boolean) => void
crashReporting: boolean
@@ -300,9 +295,7 @@ export function SettingsPanel({
onCopyMcpConfig,
vaults = [],
defaultWorkspacePath = null,
onRemoveVault,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity,
isGitVault = true,
explicitOrganizationEnabled = true,
onSaveExplicitOrganization,
@@ -321,9 +314,7 @@ export function SettingsPanel({
onCopyMcpConfig={onCopyMcpConfig}
vaults={vaults}
defaultWorkspacePath={defaultWorkspacePath}
onRemoveVault={onRemoveVault}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
{...{ onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity }}
isGitVault={isGitVault}
explicitOrganizationEnabled={explicitOrganizationEnabled}
onSaveExplicitOrganization={onSaveExplicitOrganization}
@@ -350,9 +341,7 @@ function SettingsPanelInner({
onCopyMcpConfig,
vaults,
defaultWorkspacePath,
onRemoveVault,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity,
isGitVault,
explicitOrganizationEnabled,
onSaveExplicitOrganization,
@@ -455,9 +444,7 @@ function SettingsPanelInner({
onCopyMcpConfig={onCopyMcpConfig}
vaults={vaults ?? []}
defaultWorkspacePath={defaultWorkspacePath}
onRemoveVault={onRemoveVault}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
{...{ onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity }}
setThemeMode={handleThemeModeChange}
setHideGitignoredFiles={handleGitignoredVisibilityChange}
setAllNotesFileVisibility={handleAllNotesFileVisibilityChange}
@@ -499,9 +486,7 @@ interface SettingsBodyFromDraftProps {
onCopyMcpConfig?: () => void
vaults: VaultOption[]
defaultWorkspacePath?: string | null
onRemoveVault?: (path: string) => void
onSetDefaultWorkspace?: (path: string) => void
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
onRemoveVault?: (path: string) => void; onReorderVaults?: (orderedPaths: string[]) => void; onSetDefaultWorkspace?: (path: string) => void; onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
setThemeMode: (value: ThemeMode) => void
setHideGitignoredFiles: (value: boolean) => void
setAllNotesFileVisibility: (value: AllNotesFileVisibility) => void
@@ -518,9 +503,7 @@ function SettingsBodyFromDraft({
onCopyMcpConfig,
vaults,
defaultWorkspacePath,
onRemoveVault,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity,
setThemeMode,
setHideGitignoredFiles,
setAllNotesFileVisibility,
@@ -573,9 +556,7 @@ function SettingsBodyFromDraft({
setMultiWorkspaceEnabled={(value) => updateDraft('multiWorkspaceEnabled', value)}
vaults={vaults}
defaultWorkspacePath={defaultWorkspacePath}
onRemoveVault={onRemoveVault}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
{...{ onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity }}
explicitOrganization={draft.explicitOrganization}
setExplicitOrganization={(value) => updateDraft('explicitOrganization', value)}
crashReporting={draft.crashReporting}
@@ -618,9 +599,7 @@ function SettingsSyncAndAppearanceSections({
setMultiWorkspaceEnabled,
vaults,
defaultWorkspacePath,
onRemoveVault,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity,
themeMode,
setThemeMode,
uiLanguage,
@@ -638,15 +617,16 @@ function SettingsSyncAndAppearanceSections({
/>
</SettingsSection>
<SettingsSection id={SETTINGS_SECTION_IDS.workspaces}>
<SectionHeading title={t('settings.workspaces.title')} />
<SectionHeading
icon={<Cube size={16} aria-hidden="true" />}
title={t('settings.workspaces.title')}
/>
<WorkspaceSettingsSection
defaultWorkspacePath={defaultWorkspacePath}
enabled={multiWorkspaceEnabled}
locale={locale}
onEnabledChange={setMultiWorkspaceEnabled}
onRemoveVault={onRemoveVault}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
{...{ onRemoveVault, onReorderVaults, onSetDefaultWorkspace, onUpdateWorkspaceIdentity }}
vaults={vaults}
/>
</SettingsSection>

View File

@@ -796,6 +796,31 @@ describe('SingleEditorView', () => {
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', json)
})
it('copies CJK fenced code text from the selected DOM fragment when selection text is mojibake', async () => {
const text = 'const label = "中文測試"'
const { container } = renderEditorHarness()
const { codeBlock, code } = createCodeBlockFixture(text)
await act(async () => {
container.appendChild(codeBlock)
await Promise.resolve()
})
const range = document.createRange()
range.selectNodeContents(code)
const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({
rangeCount: 1,
isCollapsed: false,
getRangeAt: () => range,
toString: () => 'const label = "中文測試"',
} as Selection)
const clipboardData = { setData: vi.fn() }
fireEvent.copy(code, { clipboardData })
getSelection.mockRestore()
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', text)
})
it('copies fenced code from the code-block action button', async () => {
const json = '{\n "id": "Demo"\n}'
const writeText = vi.fn().mockResolvedValue(undefined)

View File

@@ -1,9 +1,7 @@
import { Component, useEffect, useCallback, useMemo, useRef, useContext, useState, type ReactNode } from 'react'
import { ArrowSquareOut as ExternalLink, Copy } from '@phosphor-icons/react'
import { Component, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { trackEvent } from '../lib/telemetry'
import {
useCreateBlockNote,
SuggestionMenuController,
BlockNoteViewRaw,
ComponentsContext,
DeleteLinkButton,
@@ -11,14 +9,15 @@ import {
LinkToolbar,
LinkToolbarController,
SideMenuController,
SuggestionMenuController,
useComponentsContext,
useCreateBlockNote,
useDictionary,
type LinkToolbarProps,
} from '@blocknote/react'
import { components } from '@blocknote/mantine'
import { MantineContext, MantineProvider } from '@mantine/core'
import { Copy } from '@phosphor-icons/react'
import { ExternalLink } from 'lucide-react'
import { trackEvent } from '../lib/telemetry'
import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode'
import { repairMalformedEditorBlocks } from '../hooks/editorBlockRepair'
import { useEditorTheme } from '../hooks/useTheme'
@@ -421,7 +420,7 @@ function selectedCodeBlockText(options: {
const range = selectedCodeBlockRange(options)
if (!range) return null
return options.selection?.toString() || range.cloneContents().textContent || ''
return range.cloneContents().textContent || options.selection?.toString() || ''
}
function selectedEditorRange(selection: Selection | null, container: HTMLElement): Range | null {

View File

@@ -95,6 +95,7 @@ interface StatusBarProps {
buildNumber?: string
onCheckForUpdates?: () => void
onRemoveVault?: (path: string) => void
onReorderVaults?: (orderedPaths: string[]) => void
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
aiFeaturesEnabled?: boolean
mcpStatus?: McpStatus
@@ -148,6 +149,7 @@ function StatusBarPrimaryFromFooter({
buildNumber,
onCheckForUpdates,
onRemoveVault,
onReorderVaults,
onUpdateWorkspaceIdentity,
aiFeaturesEnabled = true,
mcpStatus,
@@ -198,6 +200,7 @@ function StatusBarPrimaryFromFooter({
buildNumber={buildNumber}
onCheckForUpdates={onCheckForUpdates}
onRemoveVault={onRemoveVault}
onReorderVaults={onReorderVaults}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
mcpStatus={aiFeaturesEnabled ? mcpStatus : undefined}
onInstallMcp={onInstallMcp}

View File

@@ -1,21 +1,16 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type MutableRefObject, type PointerEvent as ReactPointerEvent } from 'react'
import { getAssetUrlsByImport } from '@tldraw/assets/imports.vite'
import { Dialog as DialogPrimitive } from 'radix-ui'
import {
Box,
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TldrawUiButton,
TldrawUiButtonIcon,
TldrawUiMenuContextProvider,
Tldraw,
createTLStore,
defaultUserPreferences,
getSnapshot,
loadSnapshot,
react as reactToTldrawSignal,
useDialogs,
useTldrawUser,
useTranslation,
useValue,
type Editor,
type TLEventInfo,
type TLUiDialog,
@@ -35,7 +30,6 @@ function resolveTldrawAssetUrl(assetUrl: string | undefined): string {
}
const tldrawAssetUrls = getAssetUrlsByImport(resolveTldrawAssetUrl)
const tldrawUiComponents = { Dialogs: TolariaTldrawDialogs }
interface TldrawWhiteboardProps {
boardId: string
@@ -221,43 +215,7 @@ interface TolariaTldrawDialogProps {
}
const DIALOG_OPEN_DISMISS_GRACE_MS = 250
function isKeyboardShortcutsDialog(dialog: TLUiDialog): boolean {
return dialog.component === DefaultKeyboardShortcutsDialog
}
interface TolariaKeyboardShortcutsDialogProps {
onClose: () => void
}
function TolariaKeyboardShortcutsDialog({ onClose }: TolariaKeyboardShortcutsDialogProps) {
const msg = useTranslation()
return (
<>
<div className="tlui-dialog__header tlui-shortcuts-dialog__header">
<div className="tlui-dialog__header__title">{msg('shortcuts-dialog.title')}</div>
<div className="tlui-dialog__header__close">
<TldrawUiButton
type="icon"
aria-label={msg('ui.close')}
data-testid="dialog.close"
onClick={onClose}
title={msg('ui.close')}
>
<TldrawUiButtonIcon small icon="cross-2" />
</TldrawUiButton>
</div>
</div>
<div className="tlui-dialog__body tlui-shortcuts-dialog__body" tabIndex={0}>
<TldrawUiMenuContextProvider type="keyboard-shortcuts" sourceId="kbd">
<DefaultKeyboardShortcutsDialogContent />
</TldrawUiMenuContextProvider>
</div>
<div className="tlui-dialog__scrim" />
</>
)
}
let retainedTolariaTldrawDialogs: TLUiDialog[] = []
function useDeferredDialogOpen() {
const openedAtRef = useRef(0)
@@ -292,14 +250,12 @@ function shouldCloseFromOverlayClick(
interface TolariaTldrawDialogContentProps {
dialog: TLUiDialog
dialogTitle: string | undefined
mouseDownInsideContentRef: MutableRefObject<boolean>
onClose: () => void
}
function TolariaTldrawDialogContent({
dialog,
dialogTitle,
mouseDownInsideContentRef,
onClose,
}: TolariaTldrawDialogContentProps) {
@@ -314,14 +270,17 @@ function TolariaTldrawDialogContent({
dir="ltr"
className="tlui-dialog__content"
aria-describedby={undefined}
aria-label={dialogTitle}
role="dialog"
onKeyDown={(event) => {
if (event.key !== 'Escape') return
event.preventDefault()
mouseDownInsideContentRef.current = false
onClose()
}}
onMouseDown={() => { mouseDownInsideContentRef.current = true }}
onMouseUp={() => { mouseDownInsideContentRef.current = false }}
>
{isKeyboardShortcutsDialog(dialog)
? <TolariaKeyboardShortcutsDialog onClose={handleClose} />
: <ModalContent onClose={handleClose} />}
<ModalContent onClose={handleClose} />
</div>
)
}
@@ -329,57 +288,64 @@ function TolariaTldrawDialogContent({
const TolariaTldrawDialog = memo(function TolariaTldrawDialog({ dialog, onClose }: TolariaTldrawDialogProps) {
const mouseDownInsideContentRef = useRef(false)
const { openedAtRef, readyToOpen } = useDeferredDialogOpen()
const msg = useTranslation()
const dialogTitle = isKeyboardShortcutsDialog(dialog) ? msg('shortcuts-dialog.title') : undefined
const closeDialog = useCallback(() => {
const closeDialogFromBackground = useCallback(() => {
if (!canDismissDialog(openedAtRef.current)) return
onClose(dialog.id)
}, [dialog.id, onClose, openedAtRef])
const closeDialogNow = useCallback(() => { onClose(dialog.id) }, [dialog.id, onClose])
const handleOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen) closeDialogNow()
}, [closeDialogNow])
if (!readyToOpen) return null
return (
<div
dir="ltr"
className="tlui-dialog__overlay"
onMouseDown={(event) => {
if (event.target === event.currentTarget) mouseDownInsideContentRef.current = false
}}
onClick={(event) => {
if (shouldCloseFromOverlayClick(event, dialog, mouseDownInsideContentRef.current)) closeDialog()
}}
>
<TolariaTldrawDialogContent
dialog={dialog}
dialogTitle={dialogTitle}
mouseDownInsideContentRef={mouseDownInsideContentRef}
onClose={closeDialogNow}
/>
</div>
<DialogPrimitive.Root open onOpenChange={handleOpenChange}>
<div
dir="ltr"
className="tlui-dialog__overlay"
onMouseDown={(event) => {
if (event.target === event.currentTarget) mouseDownInsideContentRef.current = false
}}
onClick={(event) => {
if (shouldCloseFromOverlayClick(event, dialog, mouseDownInsideContentRef.current)) {
closeDialogFromBackground()
}
}}
>
<TolariaTldrawDialogContent
dialog={dialog}
mouseDownInsideContentRef={mouseDownInsideContentRef}
onClose={closeDialogNow}
/>
</div>
</DialogPrimitive.Root>
)
})
function TolariaTldrawDialogs() {
const { dialogs, removeDialog } = useDialogs()
const [visibleDialogs, setVisibleDialogs] = useState<TLUiDialog[]>(() => dialogs.get())
const requestedDialogs = useValue('tolaria tldraw dialogs', () => dialogs.get(), [dialogs])
const [visibleDialogs, setVisibleDialogs] = useState<TLUiDialog[]>(() =>
retainedTolariaTldrawDialogs.length > 0 ? retainedTolariaTldrawDialogs : dialogs.get()
)
const closeVisibleDialog = useCallback((id: string) => {
setVisibleDialogs((current) => current.filter((dialog) => dialog.id !== id))
const nextDialogs = retainedTolariaTldrawDialogs.filter((dialog) => dialog.id !== id)
retainedTolariaTldrawDialogs = nextDialogs
setVisibleDialogs(nextDialogs)
removeDialog(id)
}, [removeDialog])
useEffect(() => reactToTldrawSignal(
'tolaria tldraw dialogs',
() => {
const nextDialogs = dialogs.get()
if (nextDialogs.length > 0) {
// tldraw clears the dialog atom while Radix closes the menu; keep the last requested dialog mounted locally.
setVisibleDialogs(nextDialogs)
}
}
), [dialogs])
useEffect(() => {
if (requestedDialogs.length === 0) return
// tldraw clears the dialog atom while Radix closes the menu; keep the last requested dialog mounted locally.
retainedTolariaTldrawDialogs = requestedDialogs
queueMicrotask(() => {
setVisibleDialogs(requestedDialogs)
})
}, [requestedDialogs])
return visibleDialogs.map((dialog) => (
<TolariaTldrawDialog
@@ -411,6 +377,7 @@ export function TldrawWhiteboard({
setUserPreferences: ignoreTldrawUserPreferencesUpdate,
userPreferences,
})
const tldrawUiComponents = useMemo(() => ({ Dialogs: TolariaTldrawDialogs }), [])
useEffect(() => {
onSnapshotChangeRef.current = onSnapshotChange

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from 'react'
import { Download, ExternalLink, RefreshCw, X } from 'lucide-react'
import { ArrowSquareOut as ExternalLink, ArrowsClockwise as RefreshCw, Download, X } from '@phosphor-icons/react'
import type { UpdateStatus, UpdateActions } from '../hooks/useUpdater'
import { restartApp } from '../hooks/useUpdater'
import { Button } from './ui/button'

View File

@@ -1,3 +1,4 @@
import { Article } from '@phosphor-icons/react'
import type { TranslationKey, TranslationValues } from '../lib/i18n'
import type { NoteWidthMode } from '../types'
import type { AllNotesFileVisibility } from '../utils/allNotesFileVisibility'
@@ -76,6 +77,7 @@ export function VaultContentSettingsSection({
return (
<>
<SectionHeading
icon={<Article size={16} aria-hidden="true" />}
title={t('settings.vaultContent.title')}
/>

View File

@@ -1,6 +1,6 @@
import { CircleNotch as Loader2, FolderOpen, Plus, Rocket, Warning as AlertTriangle } from '@phosphor-icons/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from '@/components/ui/button'
import tolariaIcon from '@/assets/tolaria-icon.svg'

View File

@@ -0,0 +1,59 @@
import { CaretDown, CaretUp } from '@phosphor-icons/react'
import { Button } from './ui/button'
import { createTranslator, type AppLocale } from '../lib/i18n'
import type { VaultOption } from './status-bar/types'
import type { VaultMoveDirection } from '../utils/vaultOrdering'
import type { workspaceIdentityFromVault } from '../utils/workspaces'
interface WorkspaceMoveButtonsProps {
canMoveDown: boolean
canMoveUp: boolean
locale: AppLocale
onMoveVault?: (path: string, direction: VaultMoveDirection) => void
vault: VaultOption
workspace: ReturnType<typeof workspaceIdentityFromVault>
}
export function WorkspaceMoveButtons({
canMoveDown,
canMoveUp,
locale,
onMoveVault,
vault,
workspace,
}: WorkspaceMoveButtonsProps) {
const t = createTranslator(locale)
const moveUpLabel = t('settings.workspaces.moveUpAria', { label: workspace.label })
const moveDownLabel = t('settings.workspaces.moveDownAria', { label: workspace.label })
return (
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onMoveVault?.(vault.path, 'up')}
disabled={!onMoveVault || !canMoveUp}
aria-label={moveUpLabel}
title={moveUpLabel}
data-testid={`settings-workspace-move-up-${workspace.alias}`}
className="text-muted-foreground hover:text-foreground"
>
<CaretUp size={15} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onMoveVault?.(vault.path, 'down')}
disabled={!onMoveVault || !canMoveDown}
aria-label={moveDownLabel}
title={moveDownLabel}
data-testid={`settings-workspace-move-down-${workspace.alias}`}
className="text-muted-foreground hover:text-foreground"
>
<CaretDown size={15} />
</Button>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { CaretUpDown, Check } from '@phosphor-icons/react'
import { CaretUpDown, Check, Cube } from '@phosphor-icons/react'
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
import type { WorkspaceIdentity } from '../types'
import { Button } from '@/components/ui/button'
@@ -9,6 +9,7 @@ import { translate, type AppLocale } from '../lib/i18n'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
import {
PROPERTY_PANEL_LABEL_CLASS_NAME,
PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME,
PROPERTY_PANEL_ROW_STYLE,
} from './propertyPanelLayout'
@@ -95,6 +96,12 @@ function reportWorkspaceChangeFailure(error: unknown) {
function WorkspaceRowLabel({ locale }: { locale: AppLocale }) {
return (
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
<span
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
data-testid="workspace-row-icon-slot"
>
<Cube size={14} className="shrink-0" data-testid="workspace-row-icon" />
</span>
<span className="min-w-0 truncate">{translate(locale, 'inspector.properties.workspace')}</span>
</span>
)

View File

@@ -0,0 +1,292 @@
import { Info, Trash as Trash2 } from '@phosphor-icons/react'
import { type KeyboardEvent, useState } from 'react'
import { AccentColorPicker } from './AccentColorPicker'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { SettingsGroupItem } from './SettingsControls'
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'
import { WorkspaceMoveButtons } from './WorkspaceMoveButtons'
import { createTranslator, type AppLocale } from '../lib/i18n'
import { trackEvent } from '../lib/telemetry'
import type { VaultOption } from './status-bar/types'
import { canMoveVaultPath, moveVaultPath, type VaultMoveDirection } from '../utils/vaultOrdering'
import { workspaceIdentityFromVault } from '../utils/workspaces'
interface WorkspaceSettingsRowsProps {
defaultWorkspacePath?: string | null
locale: AppLocale
onRemoveVault?: (path: string) => void
onReorderVaults?: (orderedPaths: string[]) => void
onSetDefaultWorkspace?: (path: string) => void
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
setVaultPendingRemoval: (vault: VaultOption) => void
vaults: VaultOption[]
}
function workspaceInputId(alias: string, field: 'name' | 'label' | 'slug'): string {
return `settings-workspace-${alias}-${field}`
}
function sanitizeWorkspaceShortLabel(value: string): string {
return value.trim().toUpperCase().slice(0, 3)
}
function WorkspaceFieldLabel({
htmlFor,
label,
tooltip,
}: {
htmlFor: string
label: string
tooltip: string
}) {
return (
<div className="flex min-w-0 items-center gap-1">
<label className="truncate text-[11px] font-medium text-muted-foreground" htmlFor={htmlFor}>
{label}
</label>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-4 w-4 shrink-0 rounded-full p-0 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={tooltip}
>
<Info size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="start">{tooltip}</TooltipContent>
</Tooltip>
</div>
)
}
function WorkspaceIdentityInputs({
canEdit,
locale,
onUpdateWorkspaceIdentity,
vault,
}: Pick<WorkspaceSettingsRowsProps, 'locale' | 'onUpdateWorkspaceIdentity'> & {
canEdit: boolean
vault: VaultOption
}) {
const t = createTranslator(locale)
const workspace = workspaceIdentityFromVault(vault)
const nameId = workspaceInputId(workspace.alias, 'name')
const labelId = workspaceInputId(workspace.alias, 'label')
const slugId = workspaceInputId(workspace.alias, 'slug')
const savedNameDraft = vault.label ?? ''
const savedShortLabelDraft = sanitizeWorkspaceShortLabel(vault.shortLabel || workspace.shortLabel)
const [nameDraft, setNameDraft] = useState(savedNameDraft)
const [shortLabelDraft, setShortLabelDraft] = useState(savedShortLabelDraft)
const commitNameDraft = () => {
if (!canEdit || nameDraft === savedNameDraft) return
onUpdateWorkspaceIdentity?.(vault.path, { label: nameDraft })
}
const commitShortLabelDraft = () => {
const normalizedShortLabel = sanitizeWorkspaceShortLabel(shortLabelDraft)
if (!canEdit || normalizedShortLabel === savedShortLabelDraft) return
onUpdateWorkspaceIdentity?.(vault.path, { shortLabel: normalizedShortLabel })
}
const blurOnEnter = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') event.currentTarget.blur()
}
return (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(80px,0.28fr)_minmax(140px,0.55fr)]">
<div className="grid gap-1.5">
<WorkspaceFieldLabel htmlFor={nameId} label={t('settings.workspaces.name')} tooltip={t('settings.workspaces.nameTooltip')} />
<Input
id={nameId}
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onBlur={commitNameDraft}
onKeyDown={blurOnEnter}
aria-label={t('settings.workspaces.nameAria', { label: workspace.label })}
disabled={!canEdit}
className="h-8 bg-transparent"
/>
</div>
<div className="grid gap-1.5">
<WorkspaceFieldLabel htmlFor={labelId} label={t('settings.workspaces.label')} tooltip={t('settings.workspaces.labelTooltip')} />
<Input
id={labelId}
value={shortLabelDraft}
maxLength={3}
onChange={(event) => setShortLabelDraft(sanitizeWorkspaceShortLabel(event.target.value))}
onBlur={commitShortLabelDraft}
onKeyDown={blurOnEnter}
aria-label={t('settings.workspaces.labelAria', { label: workspace.label })}
disabled={!canEdit}
className="h-8 bg-transparent uppercase"
/>
</div>
<div className="grid gap-1.5">
<WorkspaceFieldLabel htmlFor={slugId} label={t('settings.workspaces.slug')} tooltip={t('settings.workspaces.slugTooltip')} />
<Input
id={slugId}
value={vault.alias ?? workspace.alias}
aria-label={t('settings.workspaces.slugAria', { label: workspace.label })}
readOnly
aria-readonly="true"
className="h-8 cursor-default bg-muted/30 text-muted-foreground"
/>
</div>
</div>
)
}
function WorkspaceRowActions({
canEdit,
canMoveDown,
canMoveUp,
locale,
onMoveVault,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault,
workspace,
vault,
}: Pick<WorkspaceSettingsRowsProps, 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity'> & {
canEdit: boolean
canMoveDown: boolean
canMoveUp: boolean
onMoveVault?: (path: string, direction: VaultMoveDirection) => void
workspace: ReturnType<typeof workspaceIdentityFromVault>
vault: VaultOption
}) {
const t = createTranslator(locale)
const removeLabel = t('settings.workspaces.removeAria', { label: workspace.label })
return (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
<WorkspaceMoveButtons canMoveDown={canMoveDown} canMoveUp={canMoveUp} locale={locale} onMoveVault={onMoveVault} vault={vault} workspace={workspace} />
<AccentColorPicker
className="gap-1.5"
disabled={!canEdit}
selectedColor={vault.color ?? null}
onSelectColor={(color) => onUpdateWorkspaceIdentity?.(vault.path, { color })}
size={18}
/>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => {
onSetDefaultWorkspace?.(vault.path)
trackEvent('workspace_default_changed', { workspace_alias: workspace.alias })
}}
disabled={!onSetDefaultWorkspace || workspace.defaultForNewNotes}
data-testid={`settings-workspace-default-${workspace.alias}`}
>
{workspace.defaultForNewNotes ? t('workspace.manager.default') : t('workspace.manager.makeDefault')}
</Button>
{onRemoveVault && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onRemoveVault(vault.path)}
disabled={workspace.defaultForNewNotes}
aria-label={removeLabel}
title={removeLabel}
data-testid={`settings-workspace-remove-${workspace.alias}`}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={15} />
</Button>
)}
</div>
)
}
function WorkspaceSettingsRow({
defaultWorkspacePath,
locale,
onMoveVault,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault,
vaults,
vault,
}: Pick<WorkspaceSettingsRowsProps, 'defaultWorkspacePath' | 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity' | 'vaults'> & {
onMoveVault?: (path: string, direction: VaultMoveDirection) => void
vault: VaultOption
}) {
const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath })
const canEdit = !!onUpdateWorkspaceIdentity && vault.path !== '' && !vault.managedDefault
return (
<div className="grid gap-3 px-4 py-4" data-testid={`settings-workspace-row-${workspace.alias}`}>
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
<div className="min-w-[220px] flex-1">
<div className="truncate text-sm font-medium text-foreground">{workspace.label}</div>
<div className="truncate text-[11px] text-muted-foreground">{workspace.path}</div>
</div>
<WorkspaceRowActions
canEdit={canEdit}
canMoveDown={canMoveVaultPath(vaults, vault.path, 'down')}
canMoveUp={canMoveVaultPath(vaults, vault.path, 'up')}
locale={locale}
onMoveVault={onMoveVault}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onRemoveVault={onRemoveVault}
workspace={workspace}
vault={vault}
/>
</div>
<WorkspaceIdentityInputs canEdit={canEdit} locale={locale} onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity} vault={vault} />
</div>
)
}
function moveVaultInList(
vaults: VaultOption[],
onReorderVaults: ((orderedPaths: string[]) => void) | undefined,
path: string,
direction: VaultMoveDirection,
) {
const orderedPaths = moveVaultPath(vaults, path, direction)
if (orderedPaths) onReorderVaults?.(orderedPaths)
}
export function WorkspaceSettingsRows({
defaultWorkspacePath,
locale,
onRemoveVault,
onReorderVaults,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
setVaultPendingRemoval,
vaults,
}: WorkspaceSettingsRowsProps) {
const moveVault = (path: string, direction: VaultMoveDirection) => {
moveVaultInList(vaults, onReorderVaults, path, direction)
}
return (
<SettingsGroupItem testId="settings-workspace-list">
<div className="-mx-4 divide-y divide-border">
{vaults.map((vault) => (
<WorkspaceSettingsRow
key={vault.path}
defaultWorkspacePath={defaultWorkspacePath}
locale={locale}
onMoveVault={onReorderVaults ? moveVault : undefined}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onRemoveVault={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
vaults={vaults}
vault={vault}
/>
))}
</div>
</SettingsGroupItem>
)
}

View File

@@ -1,16 +1,11 @@
import { Info } from '@phosphor-icons/react'
import { Trash2 } from 'lucide-react'
import { type KeyboardEvent, useState } from 'react'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'
import { useState } from 'react'
import { TooltipProvider } from './ui/tooltip'
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
import { SettingsGroup, SettingsGroupItem, SettingsSwitchRow } from './SettingsControls'
import { SettingsGroup, SettingsSwitchRow } from './SettingsControls'
import { createTranslator, type AppLocale } from '../lib/i18n'
import { trackEvent } from '../lib/telemetry'
import type { VaultOption } from './status-bar/types'
import { workspaceIdentityFromVault } from '../utils/workspaces'
import { AccentColorPicker } from './AccentColorPicker'
import { WorkspaceSettingsRows } from './WorkspaceSettingsRows'
interface WorkspaceSettingsSectionProps {
defaultWorkspacePath?: string | null
@@ -18,248 +13,19 @@ interface WorkspaceSettingsSectionProps {
locale: AppLocale
onEnabledChange: (enabled: boolean) => void
onRemoveVault?: (path: string) => void
onReorderVaults?: (orderedPaths: string[]) => void
onSetDefaultWorkspace?: (path: string) => void
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
vaults: VaultOption[]
}
function workspaceInputId(alias: string, field: 'name' | 'label' | 'slug'): string {
return `settings-workspace-${alias}-${field}`
}
function sanitizeWorkspaceShortLabel(value: string): string {
return value.trim().toUpperCase().slice(0, 3)
}
function WorkspaceFieldLabel({
htmlFor,
label,
tooltip,
}: {
htmlFor: string
label: string
tooltip: string
}) {
return (
<div className="flex min-w-0 items-center gap-1">
<label className="truncate text-[11px] font-medium text-muted-foreground" htmlFor={htmlFor}>
{label}
</label>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-4 w-4 shrink-0 rounded-full p-0 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label={tooltip}
>
<Info size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="start">{tooltip}</TooltipContent>
</Tooltip>
</div>
)
}
function WorkspaceIdentityInputs({
canEdit,
locale,
onUpdateWorkspaceIdentity,
vault,
}: Pick<WorkspaceSettingsSectionProps, 'locale' | 'onUpdateWorkspaceIdentity'> & {
canEdit: boolean
vault: VaultOption
}) {
const t = createTranslator(locale)
const workspace = workspaceIdentityFromVault(vault)
const nameId = workspaceInputId(workspace.alias, 'name')
const labelId = workspaceInputId(workspace.alias, 'label')
const slugId = workspaceInputId(workspace.alias, 'slug')
const savedNameDraft = vault.label ?? ''
const savedShortLabelDraft = sanitizeWorkspaceShortLabel(vault.shortLabel || workspace.shortLabel)
const [nameDraft, setNameDraft] = useState(savedNameDraft)
const [shortLabelDraft, setShortLabelDraft] = useState(savedShortLabelDraft)
const commitNameDraft = () => {
if (!canEdit || nameDraft === savedNameDraft) return
onUpdateWorkspaceIdentity?.(vault.path, { label: nameDraft })
}
const commitShortLabelDraft = () => {
const normalizedShortLabel = sanitizeWorkspaceShortLabel(shortLabelDraft)
if (!canEdit || normalizedShortLabel === savedShortLabelDraft) return
onUpdateWorkspaceIdentity?.(vault.path, { shortLabel: normalizedShortLabel })
}
const blurOnEnter = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.currentTarget.blur()
}
}
return (
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(80px,0.28fr)_minmax(140px,0.55fr)]">
<div className="grid gap-1.5">
<WorkspaceFieldLabel
htmlFor={nameId}
label={t('settings.workspaces.name')}
tooltip={t('settings.workspaces.nameTooltip')}
/>
<Input
id={nameId}
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
onBlur={commitNameDraft}
onKeyDown={blurOnEnter}
aria-label={t('settings.workspaces.nameAria', { label: workspace.label })}
disabled={!canEdit}
className="h-8 bg-transparent"
/>
</div>
<div className="grid gap-1.5">
<WorkspaceFieldLabel
htmlFor={labelId}
label={t('settings.workspaces.label')}
tooltip={t('settings.workspaces.labelTooltip')}
/>
<Input
id={labelId}
value={shortLabelDraft}
maxLength={3}
onChange={(event) => setShortLabelDraft(sanitizeWorkspaceShortLabel(event.target.value))}
onBlur={commitShortLabelDraft}
onKeyDown={blurOnEnter}
aria-label={t('settings.workspaces.labelAria', { label: workspace.label })}
disabled={!canEdit}
className="h-8 bg-transparent uppercase"
/>
</div>
<div className="grid gap-1.5">
<WorkspaceFieldLabel
htmlFor={slugId}
label={t('settings.workspaces.slug')}
tooltip={t('settings.workspaces.slugTooltip')}
/>
<Input
id={slugId}
value={vault.alias ?? workspace.alias}
aria-label={t('settings.workspaces.slugAria', { label: workspace.label })}
readOnly
aria-readonly="true"
className="h-8 cursor-default bg-muted/30 text-muted-foreground"
/>
</div>
</div>
)
}
function WorkspaceRowActions({
canEdit,
locale,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault,
workspace,
vault,
}: Pick<WorkspaceSettingsSectionProps, 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity'> & {
canEdit: boolean
workspace: ReturnType<typeof workspaceIdentityFromVault>
vault: VaultOption
}) {
const t = createTranslator(locale)
const removeLabel = t('settings.workspaces.removeAria', { label: workspace.label })
return (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-3">
<AccentColorPicker
className="gap-1.5"
disabled={!canEdit}
selectedColor={vault.color ?? null}
onSelectColor={(color) => onUpdateWorkspaceIdentity?.(vault.path, { color })}
size={18}
/>
<Button
type="button"
variant="secondary"
size="xs"
onClick={() => {
onSetDefaultWorkspace?.(vault.path)
trackEvent('workspace_default_changed', { workspace_alias: workspace.alias })
}}
disabled={!onSetDefaultWorkspace || workspace.defaultForNewNotes}
data-testid={`settings-workspace-default-${workspace.alias}`}
>
{workspace.defaultForNewNotes
? t('workspace.manager.default')
: t('workspace.manager.makeDefault')}
</Button>
{onRemoveVault && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onRemoveVault(vault.path)}
disabled={workspace.defaultForNewNotes}
aria-label={removeLabel}
title={removeLabel}
data-testid={`settings-workspace-remove-${workspace.alias}`}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={15} />
</Button>
)}
</div>
)
}
function WorkspaceSettingsRow({
defaultWorkspacePath,
locale,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
onRemoveVault,
vault,
}: Pick<WorkspaceSettingsSectionProps, 'defaultWorkspacePath' | 'locale' | 'onRemoveVault' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity'> & {
vault: VaultOption
}) {
const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath })
const canEdit = !!onUpdateWorkspaceIdentity && vault.path !== '' && !vault.managedDefault
return (
<div className="grid gap-3 px-4 py-4" data-testid={`settings-workspace-row-${workspace.alias}`}>
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
<div className="min-w-[220px] flex-1">
<div className="truncate text-sm font-medium text-foreground">{workspace.label}</div>
<div className="truncate text-[11px] text-muted-foreground">{workspace.path}</div>
</div>
<WorkspaceRowActions
canEdit={canEdit}
locale={locale}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onRemoveVault={onRemoveVault}
workspace={workspace}
vault={vault}
/>
</div>
<WorkspaceIdentityInputs
canEdit={canEdit}
locale={locale}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
vault={vault}
/>
</div>
)
}
export function WorkspaceSettingsSection({
defaultWorkspacePath,
enabled,
locale,
onEnabledChange,
onRemoveVault,
onReorderVaults,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
vaults,
@@ -279,21 +45,16 @@ export function WorkspaceSettingsSection({
testId="settings-multi-workspace-enabled"
/>
{enabled && (
<SettingsGroupItem testId="settings-workspace-list">
<div className="-mx-4 divide-y divide-border">
{vaults.map((vault) => (
<WorkspaceSettingsRow
key={vault.path}
defaultWorkspacePath={defaultWorkspacePath}
locale={locale}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
onRemoveVault={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
vault={vault}
/>
))}
</div>
</SettingsGroupItem>
<WorkspaceSettingsRows
defaultWorkspacePath={defaultWorkspacePath}
locale={locale}
onRemoveVault={onRemoveVault}
onReorderVaults={onReorderVaults}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
setVaultPendingRemoval={setVaultPendingRemoval}
vaults={vaults}
/>
)}
</SettingsGroup>
<ConfirmDeleteDialog

View File

@@ -1,5 +1,4 @@
import { MagnifyingGlass, Plus, SidebarSimple } from '@phosphor-icons/react'
import { Loader2 } from 'lucide-react'
import { CircleNotch as Loader2, MagnifyingGlass, Plus, SidebarSimple } from '@phosphor-icons/react'
import type { VaultEntry } from '../../types'
import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'

View File

@@ -39,6 +39,32 @@ const entry: VaultEntry = {
sidebarLabel: null,
}
const personalWorkspace = {
id: 'personal',
label: 'Personal',
alias: 'personal',
path: '/personal',
shortLabel: 'PE',
color: 'green',
icon: null,
mounted: true,
available: true,
defaultForNewNotes: true,
}
const teamWorkspace = {
id: 'team',
label: 'Team',
alias: 'team',
path: '/team',
shortLabel: 'TE',
color: 'purple',
icon: null,
mounted: true,
available: true,
defaultForNewNotes: false,
}
function renderPropertiesPanel(props: ComponentProps<typeof DynamicPropertiesPanel>) {
return render(
<TooltipProvider>
@@ -86,14 +112,17 @@ describe('property panel shared grid layout', () => {
expect(row.style.gridColumn).toBe('1 / -1')
})
it('uses the same fixed icon slot size for type, suggested, and add-property rows', () => {
it('uses the same fixed icon slot size for metadata, suggested, and add-property rows', () => {
renderPropertiesPanel({
entry,
entry: { ...entry, workspace: personalWorkspace },
frontmatter: {},
onAddProperty: vi.fn(),
onUpdateProperty: vi.fn(),
onChangeWorkspace: vi.fn(),
workspaces: [personalWorkspace, teamWorkspace],
})
expect(screen.getByTestId('workspace-row-icon-slot')).toHaveClass('size-5')
expect(screen.getByTestId('type-row-icon-slot')).toHaveClass('size-5')
screen.getAllByTestId('suggested-property-icon-slot').forEach((slot) => {
expect(slot).toHaveClass('size-5')

View File

@@ -45,6 +45,9 @@ describe('isRecoverableEditorTransformError', () => {
it('recognizes ProseMirror transform and mismatched transaction failures', () => {
expect(isRecoverableEditorTransformError(transformError())).toBe(true)
expect(isRecoverableEditorTransformError(new RangeError('Applying a mismatched transaction'))).toBe(true)
expect(isRecoverableEditorTransformError(new RangeError(
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
))).toBe(true)
expect(isRecoverableEditorTransformError(new Error('unrelated'))).toBe(false)
})
})
@@ -74,6 +77,20 @@ describe('installRichEditorTransformErrorRecovery', () => {
})
})
it('recovers invalid-content schema transactions from mixed paragraph and list editing', () => {
const schemaError = new RangeError(
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
)
const { currentDoc, view } = createView(schemaError)
installRichEditorTransformErrorRecovery(view)
expect(() => view.dispatch({ before: currentDoc })).not.toThrow()
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
reason: 'transform_error',
})
})
it('keeps non-ProseMirror dispatch failures visible', () => {
const { view } = createView(new Error('plugin failed'))

View File

@@ -47,10 +47,15 @@ function isMismatchedTransactionError(error: unknown): boolean {
return error instanceof Error && error.message.includes('Applying a mismatched transaction')
}
function isInvalidContentTransactionError(error: unknown): boolean {
return error instanceof RangeError && error.message.startsWith('Invalid content for node ')
}
export function isRecoverableEditorTransformError(error: unknown): boolean {
return error instanceof Error && (
error.name === 'TransformError'
|| isMismatchedTransactionError(error)
|| isInvalidContentTransactionError(error)
)
}

View File

@@ -1,6 +1,5 @@
import type { MouseEvent, ReactNode } from 'react'
import { Folder, Funnel, Plus } from '@phosphor-icons/react'
import { SlidersHorizontal } from 'lucide-react'
import { Folder, Funnel, Plus, SlidersHorizontal } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { translate, type AppLocale } from '../../lib/i18n'
import { SidebarGroupHeader } from './SidebarGroupHeader'

View File

@@ -11,9 +11,8 @@ import {
SortableContext, useSortable, verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { SlidersHorizontal } from 'lucide-react'
import {
ArrowLeft, ArrowRight, Palette, PencilSimple, Plus, SidebarSimple, Trash,
ArrowLeft, ArrowRight, Palette, PencilSimple, Plus, SidebarSimple, SlidersHorizontal, Trash,
} from '@phosphor-icons/react'
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../../hooks/appCommandCatalog'
import { Button } from '@/components/ui/button'

View File

@@ -1,5 +1,4 @@
import { AlertTriangle, ChevronsUpDown } from 'lucide-react'
import { Sparkle } from '@phosphor-icons/react'
import { CaretUpDown as ChevronsUpDown, Sparkle, Warning as AlertTriangle } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import {
AI_AGENT_DEFINITIONS,
@@ -327,7 +326,7 @@ export function AiAgentsBadge({
data-testid="status-ai-agents"
>
<span style={{ ...ICON_STYLE, color: showWarning ? 'var(--accent-orange)' : 'var(--muted-foreground)' }}>
<Sparkle size={13} weight="fill" />
<Sparkle size={13} weight="regular" />
{!compact && (selectedTarget.kind === 'api_model' ? selectedTarget.shortLabel : triggerLabel(defaultAgent))}
<TriggerStateIcon showWarning={showWarning} showSwitcherCue={showSwitcherCue} />
</span>

View File

@@ -1,15 +1,5 @@
import { ArrowDown, ArrowsClockwise as RefreshCw, CircleNotch as Loader2, Cpu, GitBranch, GitCommit as GitCommitHorizontal, GitDiff, Pulse, Terminal, Warning as AlertTriangle } from '@phosphor-icons/react'
import { useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
import {
AlertTriangle,
ArrowDown,
Cpu,
GitBranch,
GitCommitHorizontal,
Loader2,
RefreshCw,
Terminal,
} from 'lucide-react'
import { GitDiff, Pulse } from '@phosphor-icons/react'
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'

View File

@@ -1,5 +1,4 @@
import { BookOpen, Moon, Package, Settings, Sun } from 'lucide-react'
import { Megaphone } from '@phosphor-icons/react'
import { BookOpen, GearSix as Settings, Megaphone, Moon, Package, Sun, type IconProps } from '@phosphor-icons/react'
import type { ComponentType, MouseEventHandler } from 'react'
import type { AiAgentId, AiAgentsStatus } from '../../lib/aiAgents'
import type { AiModelProvider } from '../../lib/aiTargets'
@@ -72,6 +71,7 @@ interface StatusBarPrimarySectionProps {
buildNumber?: string
onCheckForUpdates?: () => void
onRemoveVault?: (path: string) => void
onReorderVaults?: (orderedPaths: string[]) => void
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
@@ -132,7 +132,7 @@ function BuildNumberButton({
data-testid="status-build-number"
>
<span style={ICON_STYLE}>
<Package size={13} />
<Package size={13} weight="regular" />
{compact ? null : buildNumber ?? translate(locale, 'status.build.unknown')}
</span>
</Button>
@@ -301,7 +301,7 @@ function StatusBarPrimaryBadges({
type StatusLinkButtonProps = {
compact: boolean
icon: ComponentType<{ size?: number }>
icon: ComponentType<IconProps>
labelKey: TranslationKey
locale: AppLocale
onClick: MouseEventHandler<HTMLButtonElement>
@@ -333,7 +333,7 @@ function StatusLinkButton({
aria-label={translate(locale, tooltipKey)}
data-testid={testId}
>
<Icon size={14} />
<Icon size={14} weight="regular" />
{compact ? null : translate(locale, labelKey)}
</Button>
</ActionTooltip>
@@ -431,7 +431,9 @@ export function StatusBarPrimarySection({
onOpenConflictResolver,
buildNumber,
onCheckForUpdates,
onRemoveVault, onUpdateWorkspaceIdentity,
onRemoveVault,
onReorderVaults,
onUpdateWorkspaceIdentity,
mcpStatus,
onInstallMcp,
aiAgentsStatus,
@@ -448,13 +450,7 @@ export function StatusBarPrimarySection({
stacked = false,
compact = false,
}: StatusBarPrimarySectionProps) {
const {
openAddRemote,
closeAddRemote,
showAddRemote,
visibleRemoteStatus,
handleRemoteConnected,
} = useStatusBarAddRemote({
const { openAddRemote, closeAddRemote, showAddRemote, visibleRemoteStatus, handleRemoteConnected } = useStatusBarAddRemote({
vaultPath,
isGitVault,
remoteStatus,
@@ -475,8 +471,7 @@ export function StatusBarPrimarySection({
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
{...{ onRemoveVault, onReorderVaults, onUpdateWorkspaceIdentity }}
compact={compact}
locale={locale}
/>
@@ -584,7 +579,7 @@ export function StatusBarSecondarySection({
aria-label={themeTooltip.label}
data-testid="status-theme-mode"
>
<ThemeIcon size={14} />
<ThemeIcon size={14} weight="regular" />
</Button>
</ActionTooltip>
<ActionTooltip copy={{ label: translate(locale, 'status.settings.open'), ...SETTINGS_SHORTCUT }} side="top" align="end">
@@ -597,7 +592,7 @@ export function StatusBarSecondarySection({
aria-label={translate(locale, 'status.settings.open')}
data-testid="status-settings"
>
<Settings size={14} />
<Settings size={14} weight="regular" />
</Button>
</ActionTooltip>
</div>

View File

@@ -1,6 +1,13 @@
import { Check, Cube, FolderOpen, GitBranch, Plus, Rocket, Warning as AlertTriangle, X } from '@phosphor-icons/react'
import { useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, X } from 'lucide-react'
import {
DndContext, PointerSensor, closestCenter, type DragEndEvent, useSensor, useSensors,
} from '@dnd-kit/core'
import {
SortableContext, arrayMove, useSortable, verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -23,6 +30,7 @@ interface VaultMenuProps {
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onRemoveVault?: (path: string) => void
onReorderVaults?: (orderedPaths: string[]) => void
multiWorkspaceEnabled?: boolean
onUpdateWorkspaceIdentity?: (path: string, patch: Partial<VaultOption>) => void
compact?: boolean
@@ -41,6 +49,20 @@ interface VaultMenuItemProps {
onRequestRemove?: () => void
}
interface VaultMenuListProps {
canRemove: boolean
defaultPath: string
disableMountToggleForPath: (path: string) => boolean
locale: AppLocale
multiWorkspaceEnabled: boolean
onMountedChange: (path: string, mounted: boolean) => void
onRemoveVault?: (path: string) => void
onReorderVaults?: (orderedPaths: string[]) => void
onSelectVault: (path: string) => void
setVaultPendingRemoval: (vault: VaultOption) => void
vaults: VaultOption[]
}
interface VaultMenuActionProps {
icon: ReactNode
labelKey: TranslationKey
@@ -314,6 +336,106 @@ function VaultMenuItem({
)
}
function sortableVaultPaths(vaults: VaultOption[]): string[] {
return vaults.map((vault) => vault.path)
}
function reorderedVaultPaths(vaults: VaultOption[], event: DragEndEvent): string[] | null {
const { active, over } = event
if (!over || active.id === over.id) return null
const paths = sortableVaultPaths(vaults)
const oldIndex = paths.indexOf(active.id as string)
const newIndex = paths.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return null
return arrayMove(paths, oldIndex, newIndex)
}
function SortableVaultMenuItem({
children,
id,
}: {
children: ReactNode
id: string
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id })
return (
<div
ref={setNodeRef}
style={{
cursor: isDragging ? 'grabbing' : 'grab',
opacity: isDragging ? 0.55 : 1,
transform: CSS.Transform.toString(transform),
transition,
}}
{...attributes}
{...listeners}
>
{children}
</div>
)
}
function renderVaultMenuItem({
canRemove,
defaultPath,
disableMountToggleForPath,
locale,
multiWorkspaceEnabled,
onMountedChange,
onRemoveVault,
onSelectVault,
setVaultPendingRemoval,
vault,
}: Omit<VaultMenuListProps, 'onReorderVaults' | 'vaults'> & { vault: VaultOption }) {
return (
<VaultMenuItem
vault={vault}
isActive={vault.path === defaultPath}
canRemove={canRemove && vault.path !== defaultPath}
disableMountToggle={disableMountToggleForPath(vault.path)}
locale={locale}
multiWorkspaceEnabled={multiWorkspaceEnabled}
onSelect={() => onSelectVault(vault.path)}
onMountedChange={onMountedChange}
onRequestRemove={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
/>
)
}
function VaultMenuList(props: VaultMenuListProps) {
const { onReorderVaults, vaults } = props
const vaultPaths = useMemo(() => sortableVaultPaths(vaults), [vaults])
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
const handleDragEnd = (event: DragEndEvent) => {
const reordered = reorderedVaultPaths(vaults, event)
if (reordered) onReorderVaults?.(reordered)
}
if (!onReorderVaults || vaults.length < 2) {
return vaults.map((vault) => (
<div key={vault.path}>
{renderVaultMenuItem({ ...props, vault })}
</div>
))
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={vaultPaths} strategy={verticalListSortingStrategy}>
{vaults.map((vault) => (
<SortableVaultMenuItem key={vault.path} id={vault.path}>
{renderVaultMenuItem({ ...props, vault })}
</SortableVaultMenuItem>
))}
</SortableContext>
</DndContext>
)
}
function VaultMenuHeader({
locale,
onOpenVaultSettings,
@@ -367,12 +489,96 @@ function VaultMenuAction({
)
}
function VaultMenuPopover({
actions,
canRemove,
defaultPath,
disableMountToggleForPath,
locale,
menuMinWidth,
multiWorkspaceEnabled,
onMountedChange,
onOpenVaultSettings,
onRemoveVault,
onReorderVaults,
onSelectVault,
setOpen,
setVaultPendingRemoval,
vaults,
}: VaultMenuListProps & {
actions: VaultAction[]
menuMinWidth: number
onOpenVaultSettings?: () => void
setOpen: (open: boolean) => void
}) {
return (
<div
style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: 4,
background: 'var(--sidebar)',
border: '1px solid var(--border)',
borderRadius: 6,
padding: 4,
minWidth: menuMinWidth,
boxShadow: '0 4px 12px var(--shadow-dialog)',
zIndex: 1000,
}}
data-testid="vault-menu-popover"
>
{multiWorkspaceEnabled && (
<>
<VaultMenuHeader
locale={locale}
onOpenVaultSettings={onOpenVaultSettings ? () => {
onOpenVaultSettings()
setOpen(false)
} : undefined}
/>
<div style={{ height: 1, background: 'var(--border)', margin: '2px 0 4px' }} />
</>
)}
<VaultMenuList
canRemove={canRemove}
defaultPath={defaultPath}
disableMountToggleForPath={disableMountToggleForPath}
locale={locale}
multiWorkspaceEnabled={multiWorkspaceEnabled}
onMountedChange={onMountedChange}
onRemoveVault={onRemoveVault}
onReorderVaults={onReorderVaults}
onSelectVault={onSelectVault}
setVaultPendingRemoval={setVaultPendingRemoval}
vaults={vaults}
/>
{actions.length > 0 && <div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />}
{actions.map((action) => (
<VaultMenuAction
key={action.key}
icon={action.icon}
labelKey={action.labelKey}
testId={action.testId}
accent={action.accent}
multiWorkspaceEnabled={multiWorkspaceEnabled}
locale={locale}
onClick={() => {
action.onClick()
setOpen(false)
}}
/>
))}
</div>
)
}
export function VaultMenu(props: VaultMenuProps) {
const {
vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onCreateEmptyVault,
defaultWorkspacePath, onSetDefaultWorkspace, onOpenVaultSettings,
onCloneVault, onCloneGettingStarted, onRemoveVault, multiWorkspaceEnabled = false,
onUpdateWorkspaceIdentity, compact = false, locale = 'en',
onReorderVaults, onUpdateWorkspaceIdentity, compact = false, locale = 'en',
} = props
const [open, setOpen] = useState(false)
const [vaultPendingRemoval, setVaultPendingRemoval] = useState<VaultOption | null>(null)
@@ -391,6 +597,22 @@ export function VaultMenu(props: VaultMenuProps) {
)),
[defaultPath, vaults],
)
const disableMountToggleForPath = (path: string) => (
path === defaultPath && (includedVaults.length <= 1 || !onSetDefaultWorkspace)
)
const handleSelectVault = (path: string) => {
if (multiWorkspaceEnabled && onSetDefaultWorkspace) onSetDefaultWorkspace(path)
else onSwitchVault(path)
setOpen(false)
}
const handleMountedChange = (path: string, mounted: boolean) => {
if (!mounted && path === defaultPath) {
const nextDefaultPath = includedVaults.find((includedVault) => includedVault.path !== path)?.path
if (!nextDefaultPath) return
onSetDefaultWorkspace?.(nextDefaultPath)
}
onUpdateWorkspaceIdentity?.(path, { mounted })
}
useDismissibleLayer(open, menuRef, () => setOpen(false))
@@ -416,81 +638,28 @@ export function VaultMenu(props: VaultMenuProps) {
aria-label={translate(locale, 'status.vault.switch')}
data-testid="status-vault-trigger"
>
<FolderOpen size={13} />
<Cube size={13} weight="regular" />
{compact ? null : <span className="max-w-32 truncate">{activeVaultLabel}</span>}
</Button>
</ActionTooltip>
{open && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: 0,
marginBottom: 4,
background: 'var(--sidebar)',
border: '1px solid var(--border)',
borderRadius: 6,
padding: 4,
minWidth: menuMinWidth,
boxShadow: '0 4px 12px var(--shadow-dialog)',
zIndex: 1000,
}}
data-testid="vault-menu-popover"
>
{multiWorkspaceEnabled && (
<>
<VaultMenuHeader
locale={locale}
onOpenVaultSettings={onOpenVaultSettings ? () => {
onOpenVaultSettings()
setOpen(false)
} : undefined}
/>
<div style={{ height: 1, background: 'var(--border)', margin: '2px 0 4px' }} />
</>
)}
{vaults.map((vault) => (
<VaultMenuItem
key={vault.path}
vault={vault}
isActive={vault.path === defaultPath}
canRemove={canRemove && vault.path !== defaultPath}
disableMountToggle={vault.path === defaultPath && (includedVaults.length <= 1 || !onSetDefaultWorkspace)}
locale={locale}
multiWorkspaceEnabled={multiWorkspaceEnabled}
onSelect={() => {
if (multiWorkspaceEnabled && onSetDefaultWorkspace) onSetDefaultWorkspace(vault.path)
else onSwitchVault(vault.path)
setOpen(false)
}}
onMountedChange={(path, mounted) => {
if (!mounted && path === defaultPath) {
const nextDefaultPath = includedVaults.find((includedVault) => includedVault.path !== path)?.path
if (!nextDefaultPath) return
onSetDefaultWorkspace?.(nextDefaultPath)
}
onUpdateWorkspaceIdentity?.(path, { mounted })
}}
onRequestRemove={onRemoveVault ? () => setVaultPendingRemoval(vault) : undefined}
/>
))}
{actions.length > 0 && <div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />}
{actions.map((action) => (
<VaultMenuAction
key={action.key}
icon={action.icon}
labelKey={action.labelKey}
testId={action.testId}
accent={action.accent}
multiWorkspaceEnabled={multiWorkspaceEnabled}
locale={locale}
onClick={() => {
action.onClick()
setOpen(false)
}}
/>
))}
</div>
<VaultMenuPopover
actions={actions}
canRemove={canRemove}
defaultPath={defaultPath}
disableMountToggleForPath={disableMountToggleForPath}
locale={locale}
menuMinWidth={menuMinWidth}
multiWorkspaceEnabled={multiWorkspaceEnabled}
onMountedChange={handleMountedChange}
onOpenVaultSettings={onOpenVaultSettings}
onRemoveVault={onRemoveVault}
onReorderVaults={onReorderVaults}
onSelectVault={handleSelectVault}
setOpen={setOpen}
setVaultPendingRemoval={setVaultPendingRemoval}
vaults={vaults}
/>
)}
<ConfirmDeleteDialog
open={!!vaultPendingRemoval}

View File

@@ -1,3 +1,4 @@
import { DotsSixVertical as GripVertical, Plus } from '@phosphor-icons/react'
import { SideMenuExtension, SuggestionMenu } from '@blocknote/core/extensions'
import type {
BlockNoteEditor,
@@ -15,7 +16,6 @@ import {
useExtensionState,
type SideMenuProps,
} from '@blocknote/react'
import { GripVertical, Plus } from 'lucide-react'
import {
useCallback,
useLayoutEffect,

View File

@@ -100,13 +100,13 @@ vi.mock('@mantine/core', () => ({
),
}))
vi.mock('lucide-react', () => ({
Bold: MockIcon,
ChevronDown: MockIcon,
Code2: MockIcon,
ExternalLink: MockIcon,
Italic: MockIcon,
Strikethrough: MockIcon,
vi.mock('@phosphor-icons/react', () => ({
ArrowSquareOut: MockIcon,
CaretDown: MockIcon,
Code: MockIcon,
TextB: MockIcon,
TextItalic: MockIcon,
TextStrikethrough: MockIcon,
}))
vi.mock('./tolariaEditorFormattingConfig', () => ({

View File

@@ -45,14 +45,14 @@ import {
Menu as MantineMenu,
} from '@mantine/core'
import {
Bold,
ChevronDown,
Code2,
ExternalLink,
Italic,
Strikethrough,
type LucideIcon,
} from 'lucide-react'
ArrowSquareOut as ExternalLink,
CaretDown as ChevronDown,
Code as Code2,
TextB as Bold,
TextItalic as Italic,
TextStrikethrough as Strikethrough,
type Icon as PhosphorIcon,
} from '@phosphor-icons/react'
import {
filterTolariaFormattingToolbarItems,
getTolariaBlockTypeSelectItems,
@@ -186,7 +186,7 @@ const TOLARIA_BASIC_TEXT_STYLE_ICONS = {
italic: Italic,
strike: Strikethrough,
code: Code2,
} satisfies Record<TolariaBasicTextStyle, LucideIcon>
} satisfies Record<TolariaBasicTextStyle, PhosphorIcon>
type TolariaSelectedBlock = ReturnType<
BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>['getTextCursorPosition']
@@ -417,7 +417,7 @@ function TolariaBasicTextStyleButton({
if (buttonState === undefined) return null
const Icon = Reflect.get(TOLARIA_BASIC_TEXT_STYLE_ICONS, basicTextStyle) as LucideIcon
const Icon = Reflect.get(TOLARIA_BASIC_TEXT_STYLE_ICONS, basicTextStyle) as PhosphorIcon
const copy = Reflect.get(TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS, basicTextStyle) as {
label: string
mainTooltip: string

View File

@@ -2,10 +2,10 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
CaretDown as ChevronDownIcon,
CaretLeft as ChevronLeftIcon,
CaretRight as ChevronRightIcon,
} from '@phosphor-icons/react'
import {
DayPicker,
getDefaultClassNames,

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { Check } from 'lucide-react'
import { Check } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { X as XIcon } from '@phosphor-icons/react'
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"

View File

@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { CaretRight as ChevronRightIcon, Check as CheckIcon, Circle as CircleIcon } from '@phosphor-icons/react'
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { CaretDown as ChevronDownIcon, CaretUp as ChevronUpIcon, Check as CheckIcon } from '@phosphor-icons/react'
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"

View File

@@ -0,0 +1,143 @@
import { renderHook, act } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MutableRefObject } from 'react'
import type { VaultEntry } from '../types'
import { cacheNoteContent, clearPrefetchCache } from './useTabManagement'
import {
PARSED_BLOCK_PRELOAD_DELAY_MS,
PARSED_BLOCK_PRELOAD_MIN_BYTES,
useParsedBlockPreload,
} from './editorParsedBlockPreload'
type RefSet = {
activeTabPathRef: MutableRefObject<string | null>
editorMountedRef: MutableRefObject<boolean>
foregroundWorkAtRef: MutableRefObject<number>
rawModeRef: MutableRefObject<boolean>
}
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/large.md',
filename: 'large.md',
title: 'Large',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: 'Active',
archived: false,
modifiedAt: 1_700_000_000,
createdAt: 1_700_000_000,
fileSize: PARSED_BLOCK_PRELOAD_MIN_BYTES + 1,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible: null,
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
outgoingLinks: [],
properties: {},
hasH1: true,
fileKind: 'markdown',
...overrides,
}
}
function makeRefs(overrides: Partial<{
activeTabPath: string | null
editorMounted: boolean
foregroundWorkAt: number
rawMode: boolean
}> = {}): RefSet {
return {
activeTabPathRef: { current: overrides.activeTabPath ?? '/vault/open.md' },
editorMountedRef: { current: overrides.editorMounted ?? true },
foregroundWorkAtRef: { current: overrides.foregroundWorkAt ?? 0 },
rawModeRef: { current: overrides.rawMode ?? false },
}
}
function renderParsedPreload(refs: RefSet, prepareParsedBlocks: (event: {
entry: VaultEntry | null
path: string
content: string
}) => Promise<void>) {
return renderHook(() => useParsedBlockPreload({
...refs,
prepareParsedBlocks,
}))
}
async function emitResolvedContent(entry: VaultEntry, content = '# Large\n\nBody'): Promise<void> {
await act(async () => {
cacheNoteContent(entry.path, content, entry)
await vi.advanceTimersByTimeAsync(PARSED_BLOCK_PRELOAD_DELAY_MS)
})
}
describe('useParsedBlockPreload', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-05-14T12:00:00Z'))
clearPrefetchCache()
})
afterEach(() => {
clearPrefetchCache()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('prepares parsed blocks for a warmed large markdown note after foreground work is idle', async () => {
const refs = makeRefs()
const prepareParsedBlocks = vi.fn(async () => {})
const entry = makeEntry()
renderParsedPreload(refs, prepareParsedBlocks)
await emitResolvedContent(entry, '# Large\n\nPrepared body.')
expect(prepareParsedBlocks).toHaveBeenCalledWith({
entry,
path: entry.path,
content: '# Large\n\nPrepared body.',
})
})
it('skips entries that should not compete with the foreground editor', async () => {
const activeEntry = makeEntry({ path: '/vault/active.md' })
const refs = makeRefs({ activeTabPath: activeEntry.path })
const prepareParsedBlocks = vi.fn(async () => {})
renderParsedPreload(refs, prepareParsedBlocks)
await emitResolvedContent(activeEntry)
await emitResolvedContent(makeEntry({ path: '/vault/small.md', fileSize: PARSED_BLOCK_PRELOAD_MIN_BYTES - 1 }))
await emitResolvedContent(makeEntry({ path: '/vault/file.bin', fileKind: 'binary' }))
expect(prepareParsedBlocks).not.toHaveBeenCalled()
})
it('drops a candidate that becomes active before the idle preparse starts', async () => {
const entry = makeEntry({ path: '/vault/soon-active.md' })
const refs = makeRefs()
const prepareParsedBlocks = vi.fn(async () => {})
renderParsedPreload(refs, prepareParsedBlocks)
cacheNoteContent(entry.path, '# Soon Active\n\nBody', entry)
refs.activeTabPathRef.current = entry.path
await act(async () => {
await vi.advanceTimersByTimeAsync(PARSED_BLOCK_PRELOAD_DELAY_MS)
})
expect(prepareParsedBlocks).not.toHaveBeenCalled()
})
})

View File

@@ -5,7 +5,7 @@ import { subscribeNoteContentResolved } from './noteContentCache'
export const PARSED_BLOCK_PRELOAD_MIN_BYTES = 32 * 1024
export const PARSED_BLOCK_PRELOAD_DELAY_MS = 1800
export const PARSED_BLOCK_PRELOAD_FOREGROUND_IDLE_MS = 1500
export const PARSED_BLOCK_PRELOAD_ENABLED = false
export const PARSED_BLOCK_PRELOAD_ENABLED = true
type PrepareParsedBlocks = (event: NoteContentResolvedEvent) => Promise<void>

View File

@@ -1,5 +1,215 @@
import { describe, expect, it } from 'vitest'
import { slugifyPathStem } from './editorTabContent'
import {
extractEditorBody,
getH1TextFromBlocks,
normalizeParsedImageBlocks,
replaceTitleInFrontmatter,
slugifyPathStem,
} from './editorTabContent'
function makeHeadingBlocks(
content: Array<Record<string, unknown>>,
level = 1,
) {
return [{
type: 'heading',
props: { level },
content,
}]
}
describe('extractEditorBody', () => {
it('strips frontmatter and preserves H1 heading for new note content', () => {
const content = '---\ntitle: Untitled note\ntype: Note\nstatus: Active\n---\n\n# Untitled note\n\n'
expect(extractEditorBody(content)).toBe('# Untitled note\n\n')
})
it('strips frontmatter and preserves H1 with body content', () => {
const content = '---\ntitle: Test\n---\n# Test\n\nBody text here.'
expect(extractEditorBody(content)).toBe('# Test\n\nBody text here.')
})
it('preserves H1 and body content after frontmatter', () => {
const content = '---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nFirst paragraph.\n\nSecond paragraph.'
expect(extractEditorBody(content)).toBe('# My Note\n\nFirst paragraph.\n\nSecond paragraph.')
})
it('handles content without frontmatter', () => {
const content = '# Just a Heading\n\nSome body text.'
expect(extractEditorBody(content)).toBe('# Just a Heading\n\nSome body text.')
})
it('handles content without frontmatter or heading', () => {
const content = 'Just plain text.'
expect(extractEditorBody(content)).toBe('Just plain text.')
})
it('handles completely empty content', () => {
expect(extractEditorBody('')).toBe('')
})
it('handles frontmatter-only content', () => {
const content = '---\ntitle: Empty\n---\n'
expect(extractEditorBody(content)).toBe('')
})
it('preserves wikilinks in body', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[Other Note]] for details.'
expect(extractEditorBody(content)).toBe('# Test\n\nSee [[Other Note]] for details.')
})
it('preserves non-leading headings', () => {
const content = '---\ntitle: Test\n---\n\nSome intro text.\n\n# A Heading\n\nMore text.'
expect(extractEditorBody(content)).toBe('Some intro text.\n\n# A Heading\n\nMore text.')
})
it('preserves H1 for buildNoteContent output', () => {
const content = '---\ntitle: My Project\ntype: Project\nstatus: Active\n---\n\n# My Project\n\n'
expect(extractEditorBody(content)).toBe('# My Project\n\n')
})
it('preserves an empty H1 for untitled-note content', () => {
const content = '---\ntype: Note\nstatus: Active\n---\n\n# \n\n'
expect(extractEditorBody(content)).toBe('# \n\n')
})
})
describe('getH1TextFromBlocks', () => {
it('returns text from H1 heading block', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: 'My Title', styles: {} }])
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
})
it('returns null for empty blocks', () => {
expect(getH1TextFromBlocks([])).toBeNull()
})
it('returns null for non-heading first block', () => {
const blocks = [{
type: 'paragraph',
content: [{ type: 'text', text: 'Just text' }],
}]
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for H2 heading', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: 'Subtitle' }], 2)
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('concatenates multiple text spans', () => {
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
])
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
})
it('returns null for empty H1 content', () => {
const blocks = makeHeadingBlocks([])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for whitespace-only H1', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: ' ' }])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null when blocks is null/undefined', () => {
expect(getH1TextFromBlocks(null as unknown as unknown[])).toBeNull()
expect(getH1TextFromBlocks(undefined as unknown as unknown[])).toBeNull()
})
it('filters non-text inline content', () => {
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
])
expect(getH1TextFromBlocks(blocks)).toBe('Title')
})
})
describe('replaceTitleInFrontmatter', () => {
it('replaces title value in frontmatter', () => {
const fm = '---\ntitle: Old Title\ntype: Note\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\ntype: Note\n---\n\n')
})
it('handles title with extra spaces after colon', () => {
const fm = '---\ntitle: Old Title\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\n---\n')
})
it('returns unchanged frontmatter when no title line exists', () => {
const fm = '---\ntype: Note\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntype: Note\n---\n')
})
it('replaces only the title line, not other fields', () => {
const fm = '---\ntitle: Old\ntype: Note\nstatus: Active\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'Updated')).toBe('---\ntitle: Updated\ntype: Note\nstatus: Active\n---\n\n')
})
it('handles empty string as frontmatter', () => {
expect(replaceTitleInFrontmatter('', 'Title')).toBe('')
})
})
describe('normalizeParsedImageBlocks', () => {
it('clears broken-image fallback widths from local asset image blocks', () => {
const blocks = normalizeParsedImageBlocks([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: 16,
},
}])
expect(blocks).toEqual([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: undefined,
},
}])
})
it('preserves explicit image widths and non-local image URLs', () => {
const blocks = normalizeParsedImageBlocks([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
expect(blocks).toEqual([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
})
})
describe('slugifyPathStem', () => {
it('preserves Unicode title stems for untitled rename detection', () => {

View File

@@ -2,6 +2,55 @@ import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAutoGit } from './useAutoGit'
type AutoGitOptions = Parameters<typeof useAutoGit>[0]
function defaultAutoGitOptions(onCheckpoint: AutoGitOptions['onCheckpoint']): AutoGitOptions {
return {
enabled: true,
idleThresholdSeconds: 1,
inactiveThresholdSeconds: 1,
isGitVault: true,
hasPendingChanges: true,
hasUnsavedChanges: false,
onCheckpoint,
}
}
function renderAutoGit(overrides: Partial<AutoGitOptions> = {}) {
const onCheckpoint = overrides.onCheckpoint ?? vi.fn().mockResolvedValue(true)
const hook = renderHook(() => useAutoGit({
...defaultAutoGitOptions(onCheckpoint),
...overrides,
onCheckpoint,
}))
return { onCheckpoint, ...hook }
}
async function advanceAutoGitBy(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms)
})
}
async function expectSingleCheckpointPerActivityBurst(
onCheckpoint: AutoGitOptions['onCheckpoint'],
recordActivity: () => void,
) {
await advanceAutoGitBy(1_000)
expect(onCheckpoint).toHaveBeenCalledTimes(1)
await advanceAutoGitBy(3_000)
expect(onCheckpoint).toHaveBeenCalledTimes(1)
act(() => {
recordActivity()
})
await advanceAutoGitBy(1_000)
expect(onCheckpoint).toHaveBeenCalledTimes(2)
}
describe('useAutoGit', () => {
let hasFocus = true
@@ -17,39 +66,23 @@ describe('useAutoGit', () => {
})
it('triggers an idle checkpoint after the configured threshold', async () => {
const onCheckpoint = vi.fn().mockResolvedValue(true)
renderHook(() => useAutoGit({
enabled: true,
const { onCheckpoint } = renderAutoGit({
idleThresholdSeconds: 3,
inactiveThresholdSeconds: 2,
isGitVault: true,
hasPendingChanges: true,
hasUnsavedChanges: false,
onCheckpoint,
}))
await act(async () => {
await vi.advanceTimersByTimeAsync(2_999)
})
await advanceAutoGitBy(2_999)
expect(onCheckpoint).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
await advanceAutoGitBy(1)
expect(onCheckpoint).toHaveBeenCalledWith('idle')
})
it('waits for the app to become inactive before triggering the inactive checkpoint', async () => {
const onCheckpoint = vi.fn().mockResolvedValue(true)
renderHook(() => useAutoGit({
enabled: true,
const { onCheckpoint } = renderAutoGit({
idleThresholdSeconds: 10,
inactiveThresholdSeconds: 2,
isGitVault: true,
hasPendingChanges: true,
hasUnsavedChanges: false,
onCheckpoint,
}))
})
hasFocus = false
await act(async () => {
@@ -61,53 +94,27 @@ describe('useAutoGit', () => {
})
it('does not trigger while the editor still has unsaved changes', async () => {
const onCheckpoint = vi.fn().mockResolvedValue(true)
renderHook(() => useAutoGit({
enabled: true,
idleThresholdSeconds: 1,
inactiveThresholdSeconds: 1,
isGitVault: true,
hasPendingChanges: true,
const { onCheckpoint } = renderAutoGit({
hasUnsavedChanges: true,
onCheckpoint,
}))
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
await advanceAutoGitBy(2_000)
expect(onCheckpoint).not.toHaveBeenCalled()
})
it('only triggers once per activity burst until activity is recorded again', async () => {
const onCheckpoint = vi.fn().mockResolvedValue(true)
const { result } = renderHook(() => useAutoGit({
enabled: true,
idleThresholdSeconds: 1,
inactiveThresholdSeconds: 1,
isGitVault: true,
hasPendingChanges: true,
hasUnsavedChanges: false,
const { onCheckpoint, result } = renderAutoGit()
await expectSingleCheckpointPerActivityBurst(onCheckpoint, result.current.recordActivity)
})
it('does not retry a rejected checkpoint for the same activity burst', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const onCheckpoint = vi.fn().mockRejectedValue(new Error('Author identity unknown'))
const { result } = renderAutoGit({
onCheckpoint,
}))
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(onCheckpoint).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000)
})
expect(onCheckpoint).toHaveBeenCalledTimes(1)
act(() => {
result.current.recordActivity()
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(onCheckpoint).toHaveBeenCalledTimes(2)
await expectSingleCheckpointPerActivityBurst(onCheckpoint, result.current.recordActivity)
expect(warnSpy).toHaveBeenCalledWith('[git] Auto-commit failed:', expect.any(Error))
})
})

View File

@@ -125,7 +125,10 @@ export function useAutoGit({
void onCheckpoint(trigger).then((didRun) => {
if (didRun) markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt)
}).catch((err) => console.warn('[git] Auto-commit failed:', err))
}).catch((err) => {
markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt)
console.warn('[git] Auto-commit failed:', err)
})
})
const updateAppActivity = useEffectEvent((active: boolean) => {

View File

@@ -151,6 +151,26 @@ describe('useCommitFlow', () => {
expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed')
})
it('runAutomaticCheckpoint treats commit failures as handled after showing recovery feedback', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.reject(new Error('Author identity unknown'))
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow()
let didHandleCheckpoint = false
await act(async () => {
didHandleCheckpoint = await result.current.runAutomaticCheckpoint()
})
expect(didHandleCheckpoint).toBe(true)
expect(setToastMessage).toHaveBeenCalledWith(
'Set a Git author before AutoGit can commit. Run git config --global user.name "Your Name" and git config --global user.email you@example.com.',
)
consoleSpy.mockRestore()
})
it('runAutomaticCheckpoint commits and pushes all active repositories', async () => {
const resolveRemoteStatusForVaultPath = vi.fn().mockResolvedValue({
branch: 'main',
@@ -191,6 +211,37 @@ describe('useCommitFlow', () => {
expect(setToastMessage).toHaveBeenCalledWith('AutoGit checkpointed 2 repositories')
})
it('runAutomaticCheckpoint treats multi-repository commit failures as handled', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const loadModifiedFilesForVaultPath = vi.fn((vaultPath: string) => Promise.resolve([{
path: `${vaultPath}/note.md`,
relativePath: 'note.md',
status: 'modified',
}]))
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.reject(new Error('Please tell me who you are'))
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow({
automaticVaultPaths: ['/vault', '/work'],
loadModifiedFilesForVaultPath,
})
let didHandleCheckpoint = false
await act(async () => {
didHandleCheckpoint = await result.current.runAutomaticCheckpoint()
})
expect(didHandleCheckpoint).toBe(true)
expect(loadModifiedFilesForVaultPath).toHaveBeenCalledWith('/vault')
expect(loadModifiedFilesForVaultPath).toHaveBeenCalledWith('/work')
expect(setToastMessage).toHaveBeenCalledWith(
'Set a Git author before AutoGit can commit. Run git config --global user.name "Your Name" and git config --global user.email you@example.com.',
)
consoleSpy.mockRestore()
})
it('runAutomaticCheckpoint retries push-only when local commits are already ahead', async () => {
resolveRemoteStatusForVaultPath.mockResolvedValue({ branch: 'main', ahead: 2, behind: 0, hasRemote: true })
loadModifiedFilesForVaultPath.mockResolvedValue([])

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { GitPushResult, GitRemoteStatus, ModifiedFile } from '../types'
import { trackEvent } from '../lib/telemetry'
import { isTauri, mockInvoke } from '../mock-tauri'
import { generateAutomaticCommitMessage } from '../utils/automaticCommitMessage'
import { createTranslator, type AppLocale } from '../lib/i18n'
export type CommitMode = 'push' | 'local'
@@ -27,6 +28,7 @@ interface CommitFlowConfig {
setToastMessage: (msg: string | null) => void
onPushRejected?: () => void
automaticVaultPaths?: string[]
locale?: AppLocale
manualVaultPath?: string
vaultPath: string
}
@@ -80,6 +82,7 @@ type FinalizeCheckpointConfig = Pick<
CommitFlowConfig,
'loadModifiedFiles' | 'resolveRemoteStatusForVaultPath' | 'setToastMessage' | 'onPushRejected'
>
type Translator = ReturnType<typeof createTranslator>
interface FinalizeCheckpointArgs extends FinalizeCheckpointConfig {
result: CommitResult
@@ -132,10 +135,31 @@ function isPushRejected(result: CommitResult): boolean {
return result.status === 'rejected'
}
function formatCommitError(error: unknown): string {
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function isMissingGitAuthorIdentityError(message: string): boolean {
const normalized = message.toLowerCase()
return normalized.includes('author identity unknown')
|| normalized.includes('please tell me who you are')
|| normalized.includes('unable to auto-detect email address')
|| (normalized.includes('user.email') && normalized.includes('not set'))
|| (normalized.includes('user.name') && normalized.includes('not set'))
}
function formatCommitFailureToast(error: unknown, t: Translator): string {
const message = errorMessage(error)
if (isMissingGitAuthorIdentityError(message)) return t('git.toast.missingAuthor')
return t('git.toast.commitFailed', { error: message })
}
function formatAutoGitFailureToast(error: unknown, t: Translator): string {
const message = errorMessage(error)
if (isMissingGitAuthorIdentityError(message)) return t('git.toast.missingAuthor')
return t('git.toast.autoGitFailed', { error: message })
}
function shouldRetryPush(remoteStatus: GitRemoteStatus | null): boolean {
return remoteStatus?.hasRemote === true && remoteStatus.ahead > 0
}
@@ -227,15 +251,15 @@ async function checkpointRepository(
return { action, remoteStatus, result, status: 'executed', vaultPath }
}
function multiRepositoryCheckpointToast(results: RepositoryCheckpointResult[]): string {
function multiRepositoryCheckpointToast(results: RepositoryCheckpointResult[], t: Translator): string {
const executedCount = results.filter((result) => result.status === 'executed').length
const failedCount = results.filter((result) => result.status === 'failed').length
const rejectedCount = results.filter((result) => result.result && isPushRejected(result.result)).length
if (executedCount === 0) {
const firstError = results.find((result) => result.status === 'failed')?.error
return firstError
? `AutoGit failed: ${formatCommitError(firstError)}`
return firstError !== undefined
? formatAutoGitFailureToast(firstError, t)
: 'Nothing to commit or push'
}
@@ -331,6 +355,7 @@ async function checkpointRepositories(
async function runMultipleRepositoryCheckpoint(
targetVaultPaths: string[],
config: AutomaticCheckpointRunConfig,
t: Translator,
): Promise<boolean> {
const results = await checkpointRepositories(targetVaultPaths, config)
@@ -338,13 +363,13 @@ async function runMultipleRepositoryCheckpoint(
config.onPushRejected?.()
}
config.setToastMessage(multiRepositoryCheckpointToast(results))
config.setToastMessage(multiRepositoryCheckpointToast(results, t))
await runCheckpointRefresh({
loadModifiedFiles: config.loadModifiedFiles,
resolveRemoteStatusForVaultPath: config.resolveRemoteStatusForVaultPath,
vaultPaths: targetVaultPaths,
})
return results.some((result) => result.status === 'executed')
return results.some((result) => result.status === 'executed' || result.status === 'failed')
}
function useAutomaticCheckpointAction({
@@ -357,8 +382,10 @@ function useAutomaticCheckpointAction({
onPushRejected,
automaticVaultPaths,
vaultPath,
t,
}: CommitFlowConfig & {
checkpointInFlightRef: MutableRefObject<boolean>
t: Translator
}) {
return useCallback(async ({
savePendingBeforeCommit = false,
@@ -380,13 +407,13 @@ function useAutomaticCheckpointAction({
setToastMessage,
vaultPath,
}
return targetVaultPaths.length === 1
return await (targetVaultPaths.length === 1
? runSingleRepositoryCheckpoint(targetVaultPaths[0], runConfig)
: runMultipleRepositoryCheckpoint(targetVaultPaths, runConfig)
: runMultipleRepositoryCheckpoint(targetVaultPaths, runConfig, t))
} catch (err) {
console.error('Commit failed:', err)
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
return false
setToastMessage(formatCommitFailureToast(err, t))
return true
} finally {
checkpointInFlightRef.current = false
}
@@ -399,6 +426,7 @@ function useAutomaticCheckpointAction({
resolveRemoteStatusForVaultPath,
savePending,
setToastMessage,
t,
vaultPath,
])
}
@@ -413,6 +441,7 @@ function useManualCommitPushAction({
manualVaultPath,
vaultPath,
setShowCommitDialog,
t,
}: Pick<
CommitFlowConfig,
| 'savePending'
@@ -425,6 +454,7 @@ function useManualCommitPushAction({
> & {
checkpointInFlightRef: MutableRefObject<boolean>
setShowCommitDialog: (open: boolean) => void
t: Translator
}) {
return useCallback(async (message: string) => {
setShowCommitDialog(false)
@@ -453,7 +483,7 @@ function useManualCommitPushAction({
})
} catch (err) {
console.error('Commit failed:', err)
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
setToastMessage(formatCommitFailureToast(err, t))
} finally {
checkpointInFlightRef.current = false
}
@@ -466,6 +496,7 @@ function useManualCommitPushAction({
savePending,
setShowCommitDialog,
setToastMessage,
t,
vaultPath,
])
}
@@ -553,7 +584,7 @@ function useOpenCommitDialog({
setShowCommitDialog(true)
} catch (err) {
console.error('Commit dialog failed:', err)
setToastMessage(`Commit dialog failed: ${formatCommitError(err)}`)
setToastMessage(`Commit dialog failed: ${errorMessage(err)}`)
} finally {
dialogOpeningRef.current = false
setDialogOpening(false)
@@ -582,6 +613,7 @@ export function useCommitFlow({
setToastMessage,
onPushRejected,
automaticVaultPaths,
locale,
manualVaultPath,
vaultPath,
}: CommitFlowConfig) {
@@ -591,6 +623,7 @@ export function useCommitFlow({
const checkpointInFlightRef = useRef(false)
const dialogOpeningRef = useRef(false)
const commitModeVaultPathRef = useRef<string | null>(null)
const t = useMemo(() => createTranslator(locale), [locale])
const openCommitDialog = useOpenCommitDialog({
dialogOpeningRef,
@@ -616,6 +649,7 @@ export function useCommitFlow({
onPushRejected,
automaticVaultPaths,
vaultPath,
t,
})
const handleCommitPush = useManualCommitPushAction({
@@ -628,6 +662,7 @@ export function useCommitFlow({
manualVaultPath,
vaultPath,
setShowCommitDialog,
t,
})
useCommitModeRefresh({
commitModeVaultPathRef,

View File

@@ -1,203 +1,8 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter, RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap'
import { normalizeParsedImageBlocks } from './editorTabContent'
import { cacheNoteContent, clearPrefetchCache } from './useTabManagement'
import { RICH_EDITOR_CHANGE_DEBOUNCE_MS, useEditorTabSwap } from './useEditorTabSwap'
import { cacheParsedNoteBlocks, clearParsedNoteBlockCache } from './editorParsedBlockCache'
describe('extractEditorBody', () => {
it('strips frontmatter and preserves H1 heading for new note content', () => {
const content = '---\ntitle: Untitled note\ntype: Note\nstatus: Active\n---\n\n# Untitled note\n\n'
expect(extractEditorBody(content)).toBe('# Untitled note\n\n')
})
it('strips frontmatter and preserves H1 with body content', () => {
const content = '---\ntitle: Test\n---\n# Test\n\nBody text here.'
expect(extractEditorBody(content)).toBe('# Test\n\nBody text here.')
})
it('preserves H1 and body content after frontmatter', () => {
const content = '---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nFirst paragraph.\n\nSecond paragraph.'
expect(extractEditorBody(content)).toBe('# My Note\n\nFirst paragraph.\n\nSecond paragraph.')
})
it('handles content without frontmatter', () => {
const content = '# Just a Heading\n\nSome body text.'
expect(extractEditorBody(content)).toBe('# Just a Heading\n\nSome body text.')
})
it('handles content without frontmatter or heading', () => {
const content = 'Just plain text.'
expect(extractEditorBody(content)).toBe('Just plain text.')
})
it('handles completely empty content', () => {
expect(extractEditorBody('')).toBe('')
})
it('handles frontmatter-only content', () => {
const content = '---\ntitle: Empty\n---\n'
expect(extractEditorBody(content)).toBe('')
})
it('preserves wikilinks in body', () => {
const content = '---\ntitle: Test\n---\n\n# Test\n\nSee [[Other Note]] for details.'
expect(extractEditorBody(content)).toBe('# Test\n\nSee [[Other Note]] for details.')
})
it('preserves non-leading headings', () => {
const content = '---\ntitle: Test\n---\n\nSome intro text.\n\n# A Heading\n\nMore text.'
expect(extractEditorBody(content)).toBe('Some intro text.\n\n# A Heading\n\nMore text.')
})
it('preserves H1 for buildNoteContent output', () => {
const content = '---\ntitle: My Project\ntype: Project\nstatus: Active\n---\n\n# My Project\n\n'
expect(extractEditorBody(content)).toBe('# My Project\n\n')
})
it('preserves an empty H1 for untitled-note content', () => {
const content = '---\ntype: Note\nstatus: Active\n---\n\n# \n\n'
expect(extractEditorBody(content)).toBe('# \n\n')
})
})
describe('getH1TextFromBlocks', () => {
it('returns text from H1 heading block', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: 'My Title', styles: {} }])
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
})
it('returns null for empty blocks', () => {
expect(getH1TextFromBlocks([])).toBeNull()
})
it('returns null for non-heading first block', () => {
const blocks = [{
type: 'paragraph',
content: [{ type: 'text', text: 'Just text' }],
}]
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for H2 heading', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: 'Subtitle' }], 2)
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('concatenates multiple text spans', () => {
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
])
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
})
it('returns null for empty H1 content', () => {
const blocks = makeHeadingBlocks([])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for whitespace-only H1', () => {
const blocks = makeHeadingBlocks([{ type: 'text', text: ' ' }])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null when blocks is null/undefined', () => {
expect(getH1TextFromBlocks(null as unknown as unknown[])).toBeNull()
expect(getH1TextFromBlocks(undefined as unknown as unknown[])).toBeNull()
})
it('filters non-text inline content', () => {
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
])
expect(getH1TextFromBlocks(blocks)).toBe('Title')
})
})
describe('replaceTitleInFrontmatter', () => {
it('replaces title value in frontmatter', () => {
const fm = '---\ntitle: Old Title\ntype: Note\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\ntype: Note\n---\n\n')
})
it('handles title with extra spaces after colon', () => {
const fm = '---\ntitle: Old Title\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntitle: New Title\n---\n')
})
it('returns unchanged frontmatter when no title line exists', () => {
const fm = '---\ntype: Note\n---\n'
expect(replaceTitleInFrontmatter(fm, 'New Title')).toBe('---\ntype: Note\n---\n')
})
it('replaces only the title line, not other fields', () => {
const fm = '---\ntitle: Old\ntype: Note\nstatus: Active\n---\n\n'
expect(replaceTitleInFrontmatter(fm, 'Updated')).toBe('---\ntitle: Updated\ntype: Note\nstatus: Active\n---\n\n')
})
it('handles empty string as frontmatter', () => {
expect(replaceTitleInFrontmatter('', 'Title')).toBe('')
})
})
describe('normalizeParsedImageBlocks', () => {
it('clears broken-image fallback widths from local asset image blocks', () => {
const blocks = normalizeParsedImageBlocks([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: 16,
},
}])
expect(blocks).toEqual([{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png',
previewWidth: undefined,
},
}])
})
it('preserves explicit image widths and non-local image URLs', () => {
const blocks = normalizeParsedImageBlocks([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
expect(blocks).toEqual([
{
type: 'image',
props: {
url: 'asset://localhost/%2Fvault%2Fattachments%2Fresized.png',
previewWidth: 240,
},
},
{
type: 'image',
props: {
url: 'https://example.test/preview.png',
previewWidth: 16,
},
},
])
})
})
const blocksA = [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }]
function makeTextParagraphBlock(text: string) {
@@ -248,17 +53,6 @@ function makeMockEditor(docRef: { current: unknown[] }) {
return editor
}
function makeHeadingBlocks(
content: Array<Record<string, unknown>>,
level = 1,
) {
return [{
type: 'heading',
props: { level },
content,
}]
}
function makeLongNoteBlocks(wordCount: number) {
const words = Array.from({ length: wordCount }, (_, index) => `word${index}`)
const paragraphs: unknown[] = []
@@ -512,32 +306,6 @@ describe('useEditorTabSwap raw mode sync', () => {
])
})
it('does not preparse prefetched note blocks in the background', async () => {
clearPrefetchCache()
const tabA = makeTab('a.md', 'Note A')
const tabB = {
...makeTab('b.md', 'Likely Next'),
content: '---\ntitle: Likely Next\n---\n\n# Likely Next\n\nPrepared body.',
}
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
cacheNoteContent('b.md', tabB.content)
await flushEditorTick()
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
expect.stringContaining('Prepared body.'),
)
clearPrefetchCache()
})
it('clears stale editor DOM selection before switching notes', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')

View File

@@ -438,6 +438,7 @@ describe('useNoteActions hook', () => {
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
vaultPath: '/test/vault',
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)

View File

@@ -495,6 +495,7 @@ describe('useNoteCreation hook', () => {
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Project'),
vaultPath: windowsVaultPath,
})
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
path: createdPath,
@@ -541,6 +542,7 @@ describe('useNoteCreation hook', () => {
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: expect.stringContaining('type: Note'),
vaultPath: '/test/vault',
})
expect(addPendingSave).toHaveBeenCalledWith(createdPath)
expect(removePendingSave).toHaveBeenCalledWith(createdPath)
@@ -620,10 +622,12 @@ describe('useNoteCreation hook', () => {
expect(created).toBe(true)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
path: createdPath,
vaultPath: windowsVaultPath,
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: createdPath,
content: '---\ntype: Type\n---\n\n# Recipe\n',
vaultPath: windowsVaultPath,
})
expect(openTabWithContent).toHaveBeenCalledWith(expect.objectContaining({
path: createdPath,
@@ -648,6 +652,7 @@ describe('useNoteCreation hook', () => {
expect(created).toBe(false)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
path: '/test/vault/briefing.md',
vaultPath: '/test/vault',
})
expect(vi.mocked(invoke).mock.calls.some(([command]) => command === 'create_note_content')).toBe(false)
expect(addEntry).not.toHaveBeenCalled()
@@ -669,6 +674,7 @@ describe('useNoteCreation hook', () => {
expect(created).toBe(false)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
path: '/test/vault/note.md',
vaultPath: '/test/vault',
})
expect(vi.mocked(invoke).mock.calls.some(([command]) => command === 'create_note_content')).toBe(false)
expect(addEntry).not.toHaveBeenCalled()
@@ -697,10 +703,12 @@ describe('useNoteCreation hook', () => {
expect(created).toBe(true)
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
path: '/test/vault/note.md',
vaultPath: '/test/vault',
})
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
path: '/test/vault/note.md',
content: '---\ntype: Type\n---\n\n# Note\n',
vaultPath: '/test/vault',
})
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
path: '/test/vault/note.md',

View File

@@ -423,17 +423,29 @@ function createPersistFailureMessage(entry: VaultEntry, error: unknown): string
: 'Failed to create note — disk write error'
}
/** Persist a newly created note to disk. Returns a Promise for error handling. */
export function persistNewNote(path: string, content: string): Promise<void> {
if (!isTauri()) return mockInvoke<void>('save_note_content', { path, content }).then(() => {})
return invoke<void>('create_note_content', { path, content }).then(() => {})
interface PersistNewNoteRequest {
path: string
content: string
vaultPath?: string
}
async function typeTargetExistsOnDisk(path: string): Promise<boolean> {
function createNoteContentArgs({ path, content, vaultPath }: PersistNewNoteRequest): Record<string, unknown> {
return vaultPath ? { path, content, vaultPath } : { path, content }
}
/** Persist a newly created note to disk. Returns a Promise for error handling. */
export function persistNewNote(request: PersistNewNoteRequest): Promise<void> {
const args = createNoteContentArgs(request)
if (!isTauri()) return mockInvoke<void>('save_note_content', args).then(() => {})
return invoke<void>('create_note_content', args).then(() => {})
}
async function typeTargetExistsOnDisk({ path, vaultPath }: Pick<PersistNewNoteRequest, 'path' | 'vaultPath'>): Promise<boolean> {
if (!isTauri()) return false
try {
await invoke<string>('get_note_content', { path })
const args = vaultPath ? { path, vaultPath } : { path }
await invoke<string>('get_note_content', args)
return true
} catch {
return false
@@ -441,7 +453,10 @@ async function typeTargetExistsOnDisk(path: string): Promise<boolean> {
}
async function findTypeTargetCollision(resolved: ResolvedEntry): Promise<string | null> {
if (!await typeTargetExistsOnDisk(resolved.entry.path)) return null
if (!await typeTargetExistsOnDisk({
path: resolved.entry.path,
vaultPath: resolved.entry.workspace?.path,
})) return null
return buildCreationCollisionMessage({
noun: 'type',
title: resolved.entry.title,
@@ -473,13 +488,13 @@ interface PersistCallbacks {
}
/** Persist to disk; track pending state via onStart/onEnd. */
async function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): Promise<void> {
cbs.onStart?.(path)
async function persistOptimistic(request: PersistNewNoteRequest, cbs: PersistCallbacks): Promise<void> {
cbs.onStart?.(request.path)
try {
await persistNewNote(path, content)
cbs.onPersisted?.(path)
await persistNewNote(request)
cbs.onPersisted?.(request.path)
} finally {
cbs.onEnd?.(path)
cbs.onEnd?.(request.path)
}
}
@@ -664,7 +679,11 @@ async function persistImmediateEntry(
content: string,
): Promise<boolean> {
try {
await persistOptimistic(entry.path, content, {
await persistOptimistic({
path: entry.path,
content,
vaultPath: entry.workspace?.path,
}, {
onStart: deps.addPendingSave,
onEnd: deps.removePendingSave,
onPersisted: deps.onNewNotePersisted,
@@ -869,11 +888,10 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
if (options?.openTab !== false) openTabWithContent(resolved.entry, resolved.content)
addEntryWithMock(resolved.entry, resolved.content, addEntry)
try {
await persistOptimistic(resolved.entry.path, resolved.content, {
onStart: addPendingSave,
onEnd: removePendingSave,
onPersisted: onNewNotePersisted,
})
await persistOptimistic(
{ path: resolved.entry.path, content: resolved.content, vaultPath: resolved.entry.workspace?.path },
{ onStart: addPendingSave, onEnd: removePendingSave, onPersisted: onNewNotePersisted },
)
if (resolved.entry.isA === 'Type') {
await onTypeStateChanged?.()
}

View File

@@ -0,0 +1,26 @@
import { useCallback, type Dispatch, type SetStateAction } from 'react'
import type { VaultOption } from '../components/status-bar/types'
import { trackEvent } from '../lib/telemetry'
import { orderVaultsByPath } from '../utils/vaultOrdering'
function persistableVault(vault: VaultOption): VaultOption {
const persistedVault = { ...vault }
delete persistedVault.managedDefault
return persistedVault
}
export function useVaultReorderAction({
allVaults,
setExtraVaults,
}: {
allVaults: VaultOption[]
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
}) {
return useCallback((orderedPaths: string[]) => {
const orderedVaults = orderVaultsByPath(allVaults, orderedPaths)
if (!orderedVaults) return
setExtraVaults(orderedVaults.map(persistableVault))
trackEvent('vault_order_changed', { vault_count: orderedVaults.length })
}, [allVaults, setExtraVaults])
}

View File

@@ -6,6 +6,8 @@ import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
import type { VaultOption } from '../components/StatusBar'
import { trackEvent } from '../lib/telemetry'
import { buildAllVaults } from '../utils/vaultCollections'
import { useVaultReorderAction } from './useVaultReorderAction'
import { useWorkspaceIdentityActions } from './useWorkspaceIdentityActions'
import { sanitizeDefaultWorkspacePath } from './vaultSwitcherSanitization'
@@ -196,7 +198,7 @@ function isUnavailableGettingStartedVault(vault: VaultOption): boolean {
}
function shouldDropPersistedGettingStartedVault(vault: VaultOption, resolvedDefaultPath: string): boolean {
return isCanonicalGettingStartedPath(vault.path, resolvedDefaultPath) || isUnavailableGettingStartedVault(vault)
return isCanonicalGettingStartedPath(vault.path, resolvedDefaultPath) && isUnavailableGettingStartedVault(vault)
}
async function checkVaultAvailability(path: string): Promise<boolean> {
@@ -341,22 +343,6 @@ function buildVisibleDefaultVaults({
return defaultVaults.filter(vault => !hiddenDefaults.includes(vault.path))
}
function buildAllVaults({
visibleDefaults,
extraVaults,
}: {
visibleDefaults: VaultOption[]
extraVaults: VaultOption[]
}): VaultOption[] {
const vaultsByPath = new Map<string, VaultOption>()
for (const vault of visibleDefaults) vaultsByPath.set(vault.path, vault)
for (const vault of extraVaults) {
const existingVault = vaultsByPath.get(vault.path)
vaultsByPath.set(vault.path, existingVault ? { ...existingVault, ...vault, path: vault.path } : vault)
}
return [...vaultsByPath.values()]
}
function applyResolvedDefaultPath({
defaultAvailable,
resolvedDefaultPath,
@@ -431,8 +417,8 @@ function useVaultCollections(
[defaultVaults, hiddenDefaults],
)
const allVaults = useMemo(
() => buildAllVaults({ visibleDefaults, extraVaults }),
[extraVaults, visibleDefaults],
() => buildAllVaults({ visibleDefaults, extraVaults, hiddenDefaults }),
[extraVaults, hiddenDefaults, visibleDefaults],
)
const isGettingStartedHidden = useMemo(
() => hiddenDefaults.includes(defaultPath),
@@ -809,6 +795,7 @@ function removeVaultFromState({
}: RemoveVaultStateOptions) {
if (isDefault) {
setHiddenDefaults(previousHidden => previousHidden.includes(removedPath) ? previousHidden : [...previousHidden, removedPath])
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
} else {
setExtraVaults(previousVaults => previousVaults.filter(vault => vault.path !== removedPath))
}
@@ -1108,6 +1095,7 @@ function useRestoreGettingStartedAction(options: RestoreGettingStartedOptions) {
}
function useVaultActions({
allVaults,
defaultAvailable,
defaultPath,
defaultWorkspacePath,
@@ -1132,6 +1120,7 @@ function useVaultActions({
const switchVault = useSwitchVaultAction(onSwitchRef, setSelectedVaultPath, setVaultPath)
const workspaceIdentityActions = useWorkspaceIdentityActions({ setDefaultWorkspacePath, setExtraVaults })
const reorderVaults = useVaultReorderAction({ allVaults, setExtraVaults })
const registerVaultSelection = useRegisterVaultSelectionAction({
defaultAvailable,
defaultPath,
@@ -1184,6 +1173,7 @@ function useVaultActions({
selectedVaultPath,
vaultPath,
}),
reorderVaults,
restoreGettingStarted: useRestoreGettingStartedAction({
defaultPath,
onToastRef,
@@ -1252,6 +1242,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
handleVaultCloned,
registerVaultSelection,
removeVault,
reorderVaults,
restoreGettingStarted,
setDefaultWorkspace,
syncVaultSelection,
@@ -1277,6 +1268,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
loaded,
registerVaultSelection,
removeVault,
reorderVaults,
restoreGettingStarted,
setDefaultWorkspace,
selectedVaultPath,

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Konflikte auflösen",
"command.git.viewChanges": "Ausstehende Änderungen anzeigen",
"git.repository.select": "Repository",
"git.toast.autoGitFailed": "AutoGit ist fehlgeschlagen: {error}",
"git.toast.commitFailed": "Commit fehlgeschlagen: {error}",
"git.toast.missingAuthor": "Legen Sie einen Git-Autor fest, bevor AutoGit einen Commit durchführen kann. Führen Sie git config --global user.name \"Ihr Name\" und git config --global user.email you@example.com aus.",
"command.view.editorOnly": "Nur Editor",
"command.view.editorNoteList": "Editor + Notizenliste",
"command.view.fullLayout": "Vollständiges Layout",
@@ -122,6 +125,8 @@
"settings.workspaces.labelTooltip": "Die kurzen Initialen, die in Notizen, Suchergebnissen, Breadcrumbs und Vault-Badges angezeigt werden.",
"settings.workspaces.slugTooltip": "Das stabile Präfix, das in vault-übergreifenden Links und Beziehungen verwendet wird. Derzeit schreibgeschützt, um zu vermeiden, dass bestehende Verweise beschädigt werden.",
"settings.workspaces.color": "Farbe",
"settings.workspaces.moveUpAria": "Vault {label} nach oben verschieben",
"settings.workspaces.moveDownAria": "Vault {label} nach unten verschieben",
"settings.workspaces.removeAria": "Vault {label} entfernen",
"settings.appearance.title": "Darstellung",
"settings.appearance.description": "Wählen Sie den App-Farbmodus aus, der für Tolaria Chrome, Editor-Oberflächen, Menüs und Dialoge verwendet werden soll.",
@@ -147,7 +152,7 @@
"settings.titles.description": "Legen Sie fest, ob Tolaria Dateinamen von unbenannten Notizen automatisch anhand der ersten H1-Überschrift synchronisiert.",
"settings.titles.autoRename": "Unbenannte Notizen automatisch anhand der ersten H1-Uberschrift umbenennen",
"settings.titles.autoRenameDescription": "Wenn diese Option aktiviert ist, benennt Tolaria Dateien mit unbenannten Notizen um, sobald die erste H1-Überschrift zu einem echten Titel wird. Deaktivieren Sie diese Option, um den Dateinamen unverändert zu lassen, bis Sie ihn manuell über die Breadcrumb-Leiste umbenennen.",
"settings.vaultContent.title": "Vault-Inhalt",
"settings.vaultContent.title": "Inhalt",
"settings.vaultContent.description": "Legen Sie fest, ob Dateien, die der .gitignore-Datei des Vaults entsprechen, in Tolaria angezeigt werden.",
"settings.dateDisplay.default": "Datumsanzeige",
"settings.dateDisplay.defaultDescription": "Wählen Sie aus, wie Tolaria Datumsangaben außerhalb von editierbaren Datumsfeldern anzeigt.",

Some files were not shown because too many files have changed in this diff Show More