Compare commits

...

56 Commits

Author SHA1 Message Date
lucaronin
6d86770abf fix: compact large ai agent context 2026-05-02 19:24:52 +02:00
lucaronin
e23ba0ae27 fix: normalize windows wikilink targets 2026-05-02 19:01:00 +02:00
lucaronin
7a97f24a0e fix: detect codex windows npm shims 2026-05-02 18:41:38 +02:00
lucaronin
20f34ab9d1 fix: preserve ai panel delete shortcuts 2026-05-02 18:12:37 +02:00
lucaronin
19b31cc96a fix: recover when active vault disappears 2026-05-02 17:53:13 +02:00
lucaronin
e5ed863d5d fix: refine sidebar folder rows 2026-05-02 17:21:58 +02:00
lucaronin
4f7f5a31e4 fix(linux): choose matching wayland preload 2026-05-02 17:01:57 +02:00
lucaronin
439e2b7f66 fix: prefetch tauri nsis tools in release ci 2026-05-02 16:45:59 +02:00
lucaronin
048d27243d fix: refine slash command menu 2026-05-02 16:39:43 +02:00
lucaronin
58e129cdbc fix: stabilize editor block dragging 2026-05-02 12:18:59 +02:00
lucaronin
713b486750 fix: render release history tabs as pills 2026-05-02 11:25:39 +02:00
lucaronin
7d66e2f8e1 fix: guard table handle add selection 2026-05-02 11:16:09 +02:00
lucaronin
cd6847d59e feat: refine titlebar navigation controls 2026-05-02 10:56:36 +02:00
lucaronin
74c9841ad7 fix(ui): refine icon picker appearance 2026-05-02 10:16:24 +02:00
lucaronin
ba41854f0e fix: ignore malformed reload entries 2026-05-02 10:00:50 +02:00
lucaronin
6532ee125a fix(vault): prevent gitignored folder scan freezes 2026-05-02 09:28:51 +02:00
lucaronin
c9698b853e fix(notes): serialize immediate note creation writes 2026-05-02 03:56:48 +02:00
lucaronin
db16f2ff5e fix(views): ignore stale saved-view deletes 2026-05-02 03:25:02 +02:00
lucaronin
1e5d83d4a3 refactor(commands): share app command manifest 2026-05-02 02:57:45 +02:00
lucaronin
062de2656e refactor(frontmatter): share canonical key rules 2026-05-02 02:16:13 +02:00
lucaronin
65d14ebf6f fix(editor): theme code blocks in light mode 2026-05-02 01:47:42 +02:00
lucaronin
0b5149ba8d fix(ai): preserve gui path for cli agents 2026-05-02 01:26:34 +02:00
lucaronin
c23a3c1caf fix: add sidebar section bottom spacing 2026-05-02 01:00:06 +02:00
lucaronin
44e2870697 fix(editor): preserve pasted markdown raw toggles 2026-05-02 00:50:21 +02:00
lucaronin
802b6b77d9 fix: align macOS header controls 2026-05-02 00:35:14 +02:00
lucaronin
bb09fcfd6f fix: repair blocknote core patch 2026-05-01 23:45:38 +02:00
lucaronin
c8ac12f3dc feat: cache note content and parsed editor blocks 2026-05-01 23:14:36 +02:00
lucaronin
fbfda2f15c fix(types): render css type colors 2026-05-01 21:02:42 +02:00
lucaronin
c4c7737e83 fix(types): guard root type filename collisions 2026-05-01 20:33:07 +02:00
lucaronin
c24c60b433 fix(editor): guard stale block side menu actions 2026-05-01 19:58:35 +02:00
lucaronin
21532de941 fix: remove legacy startup loading skeleton 2026-05-01 19:11:45 +02:00
lucaronin
f92efd72df fix: keep note windows editable 2026-05-01 18:36:48 +02:00
lucaronin
6e41328f21 fix: tolerate colliding frontmatter properties 2026-05-01 18:09:48 +02:00
lucaronin
5b2b4cd569 fix: recognize type frontmatter case-insensitively 2026-05-01 17:45:14 +02:00
Jarek Krochmalski
494aeb9533 feat: add Polish (pl-PL) translation (#475)
Add complete Polish locale covering all 525 translation keys:
- New pl-PL.json catalog with full Polish translations
- Register pl-PL in APP_LOCALES and LOCALE_DEFINITIONS in i18n.ts
- Add locale.plPL label to en.json and all existing locale catalogs
- Add pl-PL target to lara.yaml for Lara CLI sync
- Add "polish" to language settings search keywords

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 15:02:18 +02:00
lucaronin
8272e68934 fix: ignore stale rich editor beforeinput views 2026-05-01 13:33:39 +02:00
lucaronin
7ba20c1e83 fix: guard stale table handle drags 2026-05-01 12:55:23 +02:00
lucaronin
3384b9acd0 fix: make editor left block handle draggable 2026-05-01 12:15:14 +02:00
lucaronin
8b36ff6b00 fix: avoid active editor remount on unrelated vault refresh 2026-05-01 11:40:14 +02:00
lucaronin
acb35cb5df docs: add/update ADR for Tauri frontend readiness watchdog (guard — from commit 1c010dd) 2026-05-01 08:03:02 +02:00
lucaronin
a8bda0a11b fix: allow linux window controls 2026-05-01 07:01:58 +02:00
lucaronin
4acbe17e1e refactor: consolidate sidebar item interactions 2026-05-01 06:34:25 +02:00
lucaronin
09759d204c test: cover traditional chinese wikilinks 2026-05-01 06:11:09 +02:00
lucaronin
7a01ef6952 fix: recover formula note parse failures 2026-05-01 05:44:22 +02:00
lucaronin
1c010ddc22 fix: recover inert macos webview startup 2026-05-01 04:58:44 +02:00
lucaronin
0df5c7882d ci: bound vitest coverage parallelism 2026-05-01 04:32:51 +02:00
lucaronin
2d138f1564 ci: bound frontend coverage parallelism 2026-05-01 04:19:39 +02:00
lucaronin
c9ee81c5dc test: cover locale-stable note subtitles 2026-05-01 04:06:02 +02:00
lucaronin
04fa74e8d8 fix: keep mcp setup dialog scrollable 2026-05-01 03:47:13 +02:00
lucaronin
b42ed35596 refactor: consolidate blocknote stale guards 2026-05-01 03:18:19 +02:00
lucaronin
75e1a82ff0 test: cover windows type creation 2026-05-01 02:42:20 +02:00
lucaronin
2af2156526 test: cover windows typed note creation 2026-05-01 02:24:07 +02:00
lucaronin
568ada8992 fix: sanitize appimage git subprocess env 2026-05-01 02:01:49 +02:00
lucaronin
0987946cff fix: surface codex final replies 2026-05-01 01:29:46 +02:00
lucaronin
81b03f05b6 chore: ratchet codescene thresholds 2026-05-01 00:59:32 +02:00
lucaronin
ee71a00c6f feat: add paste without formatting command 2026-05-01 00:59:32 +02:00
224 changed files with 12625 additions and 3522 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=10.0
AVERAGE_THRESHOLD=9.91
AVERAGE_THRESHOLD=9.92

137
.github/scripts/prefetch-tauri-nsis.ps1 vendored Normal file
View File

@@ -0,0 +1,137 @@
$ErrorActionPreference = "Stop"
$nsisUrl = "https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip"
$nsisSha1 = "EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D"
$tauriUtilsUrl = "https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll"
$tauriUtilsSha1 = "75197FEE3C6A814FE035788D1C34EAD39349B860"
$tauriUtilsRelativePath = "Plugins\x86-unicode\additional\nsis_tauri_utils.dll"
$nsisRequiredFiles = @(
"makensis.exe",
"Bin\makensis.exe",
"Stubs\lzma-x86-unicode",
"Stubs\lzma_solid-x86-unicode",
"Include\MUI2.nsh",
"Include\FileFunc.nsh",
"Include\x64.nsh",
"Include\nsDialogs.nsh",
"Include\WinMessages.nsh",
"Include\Win\COM.nsh",
"Include\Win\Propkey.nsh",
"Include\Win\RestartManager.nsh"
)
function Get-UpperSha1 {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -Algorithm SHA1 -LiteralPath $Path).Hash.ToUpperInvariant()
}
function Test-FileSha1 {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$ExpectedSha1
)
return (Test-Path -LiteralPath $Path) -and ((Get-UpperSha1 -Path $Path) -eq $ExpectedSha1)
}
function Save-VerifiedDownload {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$OutFile,
[Parameter(Mandatory = $true)][string]$ExpectedSha1
)
$parent = Split-Path -Parent $OutFile
New-Item -ItemType Directory -Force -Path $parent | Out-Null
$tempFile = "$OutFile.download"
for ($attempt = 1; $attempt -le 5; $attempt++) {
try {
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
Invoke-WebRequest -Uri $Uri -OutFile $tempFile -TimeoutSec 120
$actualSha1 = Get-UpperSha1 -Path $tempFile
if ($actualSha1 -ne $ExpectedSha1) {
throw "SHA1 mismatch for $Uri. Expected $ExpectedSha1, got $actualSha1."
}
Move-Item -Force -LiteralPath $tempFile -Destination $OutFile
return
} catch {
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
if ($attempt -eq 5) {
throw
}
$delaySeconds = [Math]::Min(30, 5 * $attempt)
Write-Warning "Download attempt ${attempt} failed: $($_.Exception.Message). Retrying in ${delaySeconds}s."
Start-Sleep -Seconds $delaySeconds
}
}
}
function Find-MissingFile {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string[]]$RelativePaths
)
foreach ($relativePath in $RelativePaths) {
if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath))) {
return $relativePath
}
}
return $null
}
if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
throw "LOCALAPPDATA is required to resolve Tauri's Windows tool cache."
}
$tauriToolsPath = Join-Path $env:LOCALAPPDATA "tauri"
$nsisPath = Join-Path $tauriToolsPath "NSIS"
$downloadRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
[System.IO.Path]::GetTempPath()
} else {
$env:RUNNER_TEMP
}
New-Item -ItemType Directory -Force -Path $tauriToolsPath | Out-Null
$missingNsisFile = Find-MissingFile -Root $nsisPath -RelativePaths $nsisRequiredFiles
if ($missingNsisFile) {
Write-Host "Tauri NSIS cache is missing $missingNsisFile; downloading NSIS 3.11."
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $nsisPath
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $tauriToolsPath "nsis-3.11")
$zipPath = Join-Path $downloadRoot "nsis-3.11.zip"
Save-VerifiedDownload -Uri $nsisUrl -OutFile $zipPath -ExpectedSha1 $nsisSha1
Expand-Archive -Force -LiteralPath $zipPath -DestinationPath $tauriToolsPath
$extractedNsisPath = Join-Path $tauriToolsPath "nsis-3.11"
if (-not (Test-Path -LiteralPath $extractedNsisPath)) {
throw "Downloaded NSIS archive did not contain the expected nsis-3.11 directory."
}
Move-Item -Force -LiteralPath $extractedNsisPath -Destination $nsisPath
} else {
Write-Host "Tauri NSIS cache already contains NSIS 3.11."
}
$tauriUtilsPath = Join-Path $nsisPath $tauriUtilsRelativePath
if (-not (Test-FileSha1 -Path $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1)) {
Write-Host "Downloading Tauri NSIS utility plugin."
Save-VerifiedDownload -Uri $tauriUtilsUrl -OutFile $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1
} else {
Write-Host "Tauri NSIS utility plugin is already cached."
}
$missingFile = Find-MissingFile -Root $nsisPath -RelativePaths ($nsisRequiredFiles + @($tauriUtilsRelativePath))
if ($missingFile) {
throw "Tauri NSIS toolchain is incomplete after prefetch; missing $missingFile."
}
Write-Host "Tauri NSIS toolchain ready at $nsisPath."

View File

@@ -401,6 +401,16 @@ jobs:
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Cache Tauri Windows tools
uses: actions/cache@v4
with:
path: ~\AppData\Local\tauri
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
- name: Prefetch Tauri NSIS toolchain
shell: pwsh
run: ./.github/scripts/prefetch-tauri-nsis.ps1
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile

View File

@@ -455,6 +455,16 @@ jobs:
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Cache Tauri Windows tools
uses: actions/cache@v4
with:
path: ~\AppData\Local\tauri
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
- name: Prefetch Tauri NSIS toolchain
shell: pwsh
run: ./.github/scripts/prefetch-tauri-nsis.ps1
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile

View File

@@ -166,9 +166,9 @@ Asset previewability is inferred in the renderer from the filename extension (`s
### Note Content Freshness
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. Before showing cached markdown or editor-ready blocks, `useTabManagement` validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
Prepared BlockNote blocks in `useEditorTabSwap` are keyed by path plus source content. They can be built ahead of time from prefetched markdown, but they are reused only when the validated raw content for that path is identical to the source content that produced the blocks.
`useEditorTabSwap` may reuse BlockNote blocks that were already opened successfully or warmed from prefetched raw content, keyed by vault, path, and exact source content. Background warming is limited to likely next large Markdown notes and defers while the editor is unmounted, raw mode is active, or recent typing/navigation is still inside the foreground idle window. Every async editor swap carries a generation and source-content token so stale conversion results cannot overwrite newer file content or dirty editor state.
### Entity Types (isA / type)
@@ -208,7 +208,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
| Property | Type | Description |
|----------|------|-------------|
| `icon` | string | Type icon as a Phosphor name (kebab-case, e.g., "cooking-pot") |
| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
| `color` | string | Accent palette key (`red`, `purple`, `blue`, `green`, `yellow`, `orange`, `teal`, `pink`, `gray`) or a valid CSS color value such as `cyan`, `#22d3ee`, or `rgb(34, 211, 238)` |
| `order` | number | Sidebar display order (lower = higher priority) |
| `sidebar_label` | string | Custom label overriding auto-pluralization |
| `template` | string | Markdown template for new notes of this type |
@@ -309,6 +309,7 @@ type SidebarSelection =
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `src/components/sidebar/sidebarHooks.ts` owns the shared sidebar interaction primitives for menu positioning/dismissal and inline rename input behavior. Folder, Type, and saved View rows keep their domain-specific actions local, but use those primitives so right-click menus and rename fields have the same outside-click, Escape, focus, blur, and submit semantics.
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
@@ -333,6 +334,12 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
- Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood.
## Command Surface
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups.
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
## File System Integration
### Vault Scanning (Rust)
@@ -356,13 +363,13 @@ All Notes starts from Markdown notes and excludes Markdown files under `attachme
The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders under the synthetic vault-root row.
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, drains stdout while stdin is still being written, and short-circuits root `.gitignore` detection before walking for nested ignore files, so large ignored folder sets cannot deadlock the native UI while preserving Git semantics as closely as the app can reasonably support.
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
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. None of those actions mutate vault contents or bypass the backend write boundary.
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 generated stdio entry as registration, 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.
@@ -461,7 +468,7 @@ interface PulseCommit {
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
- Pulls on interval, pushes after commits
- Awaits the post-pull vault refresh so toasts land after note-list state is fresh
- Reopens the clean active tab from disk after a successful pull update so the editor and note list stay aligned
- Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor
- Detects merge conflicts → opens `ConflictResolverModal`
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
- Handles push rejection (divergence) → sets `pull_required` status
@@ -470,7 +477,7 @@ interface PulseCommit {
### External Vault Refresh
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note from disk, and closes the active tab if the file disappeared. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves.
External vault mutations are any disk writes Tolaria did not just perform through its own save path: Git pulls, AI-agent writes, filesystem watcher events, and edits from another app. These changes must route through `refreshPulledVaultState()` rather than calling `reloadVault()` in isolation. The shared refresh abstraction reloads entries, folders, and saved views together, preserves unsaved active-editor content, reopens a clean active note only when the changed-path list includes that note, and closes the active tab if the file disappeared. Unknown or unrelated watcher updates refresh vault-derived state without remounting the active editor. `useVaultWatcher` supplies changed filesystem paths to this abstraction after debouncing and after filtering recent app-owned saves.
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
@@ -555,8 +562,10 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the rendered text range for the hovered block, so H1/H2 typography, line-height, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor.
@@ -624,6 +633,7 @@ Typed ASCII arrow sequences are normalized consistently in both editor modes:
- Rich editor input mounts `createArrowLigaturesExtension()` (`src/components/arrowLigaturesExtension.ts`) into BlockNote and intercepts typed `beforeinput` events before ProseMirror commits the character.
- Raw editor input uses the CodeMirror `inputHandler` path in `useCodeMirror` so the same ligature rules apply while editing markdown source directly.
- Both paths delegate to the shared `resolveArrowLigatureInput()` helper in `src/utils/arrowLigatures.ts`, which prioritizes `<->` over partial matches, keeps paste literal, and lets escaped forms such as `\\->` and `\\<->` remain ASCII.
- The rich-editor extension treats stale, disconnected, or mid-reload ProseMirror views as a no-op. It never blocks the native input path unless it has already built and dispatched a valid ligature transaction.
## Styling
@@ -740,6 +750,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
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 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
- `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

View File

@@ -88,7 +88,7 @@ flowchart LR
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape. Large folder filtering runs on the blocking Tokio pool and drains `git check-ignore` output while feeding stdin so broad ignore matches cannot freeze the native UI thread.
#### External Change Detection
@@ -102,9 +102,9 @@ Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.m
#### Note Opening Fast Path
Note opening uses a bounded in-memory fast path split across raw content and editor-ready blocks. `useTabManagement` owns the raw markdown prefetch cache and `useEditorTabSwap` owns the prepared BlockNote block cache. Cached or preloaded markdown is never rendered directly: before reusing it, the renderer calls the `validate_note_content` Tauri command, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
Note opening uses bounded in-memory fast paths for raw content and parsed editor blocks. `useTabManagement` owns the markdown/text prefetch cache and treats every cached value as a performance hint only: identity-matched entries (`modifiedAt` + `fileSize`) can be reused immediately, while identity-missing or identity-mismatched cached text is checked with `validate_note_content`, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
The note list opportunistically preloads visible and adjacent markdown/text entries after a short idle delay. Once raw content resolves, the editor prepares BlockNote blocks in the background when it is mounted and not in raw mode. This targets the expensive markdown-to-editor conversion stage while keeping filesystem content authoritative and keeping preload memory bounded by the same cache limits as ordinary note switches.
The note list opportunistically preloads visible and adjacent markdown/text entries after a short delay. When a large warmed Markdown note resolves, `useEditorTabSwap` may parse it into a bounded parsed-block cache only after foreground editor work has been idle and the rich editor is mounted. Parsed blocks are keyed by vault, path, and exact source content; every async swap carries a generation/source-content token so stale conversion results cannot overwrite newer file content or dirty editor state. The editor never renders a preview surface that later morphs into BlockNote. See [ADR-0105](./adr/0105-editor-correctness-and-responsiveness-contract.md).
## Tech Stack
@@ -214,10 +214,10 @@ The main Tauri window derives its minimum width from the visible panes instead o
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.
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, while cross-platform custom items such as Check for Updates emit Tolaria command IDs and show 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 available system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, while the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.
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. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, while the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.
## Multi-Window (Note Windows)
@@ -249,7 +249,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs.
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
#### Agent Event Flow
@@ -298,15 +298,15 @@ The agent panel (`ai-context.ts`) builds a structured JSON snapshot from the act
```json
{
"activeNote": { "path", "title", "type", "frontmatter", "content" },
"linkedNotes": [{ "path", "title", "content" }],
"openTabs": [{ "title", "snippet" }],
"vaultMetadata": { "noteTypes", "stats", "filter" },
"references": [{ "title", "path", "type" }]
"activeNote": { "path", "title", "type", "frontmatter", "body", "wordCount", "bodyTruncated?" },
"openTabs": [{ "path", "title", "type", "frontmatter" }],
"noteList": [{ "path", "title", "type" }],
"vault": { "types", "totalNotes" },
"referencedNotes": [{ "title", "path", "type" }]
}
```
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
Large active notes are compacted into a head/tail body snapshot before they enter the CLI prompt. The snapshot records `bodyTruncated` metadata and instructs agents to call `get_note(path)` before content-sensitive edits or summaries, keeping lower-context OpenCode providers from failing on oversized active-note context while preserving access to the full note through MCP.
### Authentication
@@ -490,6 +490,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
@@ -510,8 +512,9 @@ 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. `git_push()` / `git_pull()` continue to use the same system git path
5. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input
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
5. `git_push()` / `git_pull()` continue to use the same system git path
6. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input
**Auth model:**
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
@@ -555,6 +558,11 @@ sequenceDiagram
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
T-->>VL: VaultEntry[]
VL->>T: invoke('get_modified_files')
alt Runtime vault path disappears
VL->>T: invoke('check_vault_exists')
VL-->>A: unavailable vault path + cleared stale state
A-->>U: WelcomeScreen (vault missing)
end
A->>T: useMcpStatus — check explicit MCP setup state
A->>T: sync_mcp_bridge_vault(selected path)
VL-->>A: entries ready
@@ -643,7 +651,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `ignored.rs` | Gitignored-content visibility filtering via batched `git check-ignore` |
| `ignored.rs` | Gitignored-content visibility filtering via batched, pipe-safe `git check-ignore` |
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
@@ -683,6 +691,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename_note` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts |
| `move_note_to_folder` | Crash-safe folder move that preserves the filename, reloads the moved note, and rewrites path-based wikilinks |
| `create_vault_folder` | Create a folder relative to the active vault root |
| `list_vault_folders` | Build the folder tree on the blocking Tokio pool, then apply Gitignored-content visibility → `Vec<FolderNode>` |
| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths |
| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root |
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
@@ -754,6 +763,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/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 |
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
@@ -820,7 +830,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
| `appCommandDispatcher` | Manifest-backed shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
@@ -834,6 +844,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+S | Save current note |
| Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it |
| Cmd+Shift+F | Find in vault |
| Cmd+Shift+V | Paste without Formatting into the active supported editing surface |
| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) |
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
| Cmd+19 | Switch to tab N |
@@ -844,12 +855,14 @@ Selection-dependent actions are wired through the command palette and the native
Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `src/shared/appCommandManifest.json` is the shared command/menu metadata source for command IDs, menu labels, accelerators, native-menu enablement groups, and deterministic QA flags
- `appCommandCatalog.ts` derives renderer command IDs, shortcut lookup maps, Linux menu sections, and QA metadata from that manifest
- `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`

View File

@@ -37,13 +37,13 @@ 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 the 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 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:
```bash
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib/libwayland-client.so ./Tolaria*.AppImage
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib64/libwayland-client.so.0 ./Tolaria*.AppImage
```
If your distribution stores the library elsewhere, use that path instead, for example `/usr/lib64/libwayland-client.so.0` or `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`.
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.
## Quick Start
@@ -69,6 +69,8 @@ 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.
## Directory Structure
```
@@ -157,6 +159,7 @@ tolaria/
│ ├── utils/ # Pure utility functions (~48 files)
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
│ │ ├── frontmatter.ts # TypeScript YAML parser
│ │ ├── plainTextPaste.ts # Shared Paste without Formatting command target registry
│ │ ├── platform.ts # Runtime platform + Linux chrome gating helpers
│ │ ├── ai-agent.ts # Agent stream utilities
│ │ ├── ai-chat.ts # Token estimation utilities
@@ -361,7 +364,7 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
@@ -406,7 +409,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.)
2. Add a command handler in `commands/`
3. Register it in the `generate_handler![]` macro in `lib.rs`
4. Call it from the frontend via `invoke()` in the appropriate hook
4. Call it from the frontend via `invoke()` in the appropriate hook or utility, keeping native-only permission work behind the Tauri command boundary
5. Add a mock handler in `mock-tauri.ts`
### Add a new component

View File

@@ -0,0 +1,48 @@
---
type: ADR
id: "0104"
title: "Tauri frontend readiness watchdog"
status: active
date: 2026-05-01
---
## Context
Tolaria already keeps heavy filesystem and subprocess work off the Tauri window-creation path, but that alone does not protect against a different startup failure mode: the desktop WebView can render the static HTML shell while the React app never becomes interactive.
On macOS this showed up as an inert window that looked launched but never finished mounting the real app. The failure boundary is cross-layer:
- `index.html` can paint before React commits
- React root errors can happen before the app reports itself ready
- a plain reload is acceptable as a one-time recovery, but an automatic reload loop is not
- browser/mock runs should not inherit desktop-only recovery behavior
Tolaria needs a startup contract that distinguishes “HTML painted” from “frontend actually became interactive”, and a bounded recovery path when that contract is not satisfied.
## Decision
**Tolaria uses a Tauri-only frontend-readiness watchdog that reloads the WebView at most once if React never reports startup readiness.**
Concretely:
- `index.html` installs a Tauri-only startup timer before React loads
- React dispatches a readiness signal from a mounted effect after the app shell commits
- if readiness never arrives before the timeout, the WebView reloads once
- the same one-shot reload path is available to React root error handling before readiness is marked
- `sessionStorage` tracks whether the startup reload was already attempted so Tolaria does not loop forever
- browser/mock environments keep using ordinary browser clipboard/storage behavior and do not enable this desktop startup recovery path
## Options considered
- **Tauri-only readiness watchdog with one-shot reload** (chosen): directly addresses the inert-startup failure mode, keeps recovery local to the frontend, and avoids permanent reload loops. Cost: startup now depends on a small cross-layer contract between HTML bootstrap and React.
- **Do nothing and rely on manual relaunch**: simplest implementation, but leaves users stranded in a broken-looking app state with no automatic recovery.
- **Reload on any React root error without a readiness gate**: more aggressive, but too noisy; post-startup runtime errors should not trigger surprise reloads.
- **Move recovery entirely into native Rust window/bootstrap logic**: possible, but the failure signal lives in the frontend lifecycle, so native code would still need a readiness handshake.
## Consequences
- Tolaria now distinguishes successful frontend startup from merely rendering the HTML shell.
- Desktop startup recovery is bounded to a single retry per session, reducing the chance of trapping users in reload loops.
- `index.html`, `src/main.tsx`, and `src/utils/frontendReady.ts` form a shared startup contract that future bootstrap refactors must preserve.
- Any future change that delays app-shell mount beyond the watchdog timeout must re-evaluate the timeout and readiness trigger.
- If a startup failure persists after one retry, Tolaria still surfaces the broken state instead of hiding a deeper bug behind repeated reloads.

View File

@@ -0,0 +1,39 @@
---
type: ADR
id: "0105"
title: "Editor correctness and responsiveness contract"
status: active
date: 2026-05-01
---
## Context
Tolaria notes are durable Markdown files on disk, but the rich editor renders them through BlockNote blocks. Large notes exposed a tempting optimization: show a fast Markdown preview first, then hydrate BlockNote later. In practice, that creates two renderers for the same document, visible flicker, delayed click-time lag, and more places for stale async work to race with the currently selected note.
The editor must optimize for the product priorities in this order:
1. no crashes
2. no stale content, race-condition overwrites, or lost edits
3. responsive typing and cursor movement
4. fast note-list-to-editor loading
## Decision
**Tolaria keeps a single direct editor surface for Markdown notes and treats editor content swaps as generation-checked, source-content-checked operations.** Fast loading may use raw file-content prefetching and a bounded parsed-block cache, but it must not show a separate preview that later swaps into the editor. Parsed BlockNote blocks are reusable only when their source Markdown exactly matches the content being opened, and background parsing must run only after recent typing/navigation has gone idle.
## Options considered
- **Single direct editor surface with guarded swaps plus bounded caches** (chosen): preserves one visual representation of the document, rejects stale async parse results, validates or identity-checks cached disk content before opening, and keeps typing work debounced. Cons: very large notes can still wait on BlockNote conversion when they were not warmed.
- **Fast Markdown preview followed by hidden BlockNote hydration**: improves first paint but creates flicker, delayed edit-time stalls, and duplicated rendering semantics.
- **Unbounded or eager background BlockNote parsing for likely next notes**: can make some opens faster, but competes with typing/navigation and introduces stale parse-result hazards unless heavily scheduled, bounded, and invalidated.
- **Always raw mode for large notes**: strongest responsiveness for huge files, but changes the editing experience abruptly and should be an explicit fallback rather than the default.
## Consequences
- Async editor work must prove it still matches both the latest swap generation and the latest tab source content before touching BlockNote.
- Cached raw note content must be validated against disk before it is shown unless it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`, or the content was just authored by Tolaria in the current process.
- Cached parsed BlockNote blocks must be keyed by vault, path, and exact source content, cloned on read/write, and bounded by entry count plus source byte budget.
- Background parsed-block warming is allowed only for likely next large Markdown notes after a foreground idle window; active typing, raw mode, and editor mount state must defer it.
- Dirty local editor content remains authoritative. External filesystem refreshes may replace clean notes, but must not overwrite unsaved local edits.
- Per-keystroke editor work must stay minimal. Serialization, metadata derivation, autosave, and cache updates should be debounced, coalesced, or scheduled away from active typing.
- Future large-note optimizations should target true progressive/chunked conversion or explicit raw/read-only fallback states, not a visually different preview that morphs into BlockNote.

View File

@@ -0,0 +1,31 @@
---
type: ADR
id: "0106"
title: "Shared app command manifest"
status: active
date: 2026-05-02
---
## Context
Tolaria command metadata was split across several runtime surfaces: TypeScript owned shortcut lookup and command-palette shortcut display, Rust owned native menu IDs, labels, accelerators, aliases, and enablement groups, and the Linux titlebar fallback menu duplicated another command list. Adding or changing a command required carefully editing multiple files that could drift while still compiling.
The existing renderer-first shortcut model and native-menu dedupe remain correct, but they need a single source for metadata that must be identical across those surfaces.
## Decision
**Tolaria stores cross-runtime app command metadata in `src/shared/appCommandManifest.json`, and both the renderer and Tauri native menu derive their command/menu IDs, accelerators, menu labels, menu aliases, enablement groups, and deterministic QA metadata from it.** Context-sensitive command-palette builders still own availability and execution callbacks, and OS-native menu entries remain local to the native menu implementation.
## Options considered
- **Shared JSON manifest included by TypeScript and Rust** (chosen): works in both runtimes without code generation, keeps menu metadata reviewable, and lets tests validate drift directly.
- **Generate TypeScript and Rust constants from a schema**: gives stronger compile-time types but adds a build step and a generated-file maintenance burden for a small manifest.
- **Keep duplicated constants with more tests**: reduces immediate refactor scope, but still forces every command change through parallel manual edits.
## Consequences
- New app commands that appear in native menus or shortcut QA must be added to `src/shared/appCommandManifest.json`.
- `appCommandCatalog.ts` is responsible for turning the manifest into typed renderer helpers such as `APP_COMMAND_IDS`, shortcut lookup maps, Linux menu sections, and deterministic QA definitions.
- `src-tauri/src/menu.rs` includes the same manifest JSON, builds custom menu items from it, maps overridden menu item IDs such as `file-quick-open-alias` back to their primary command IDs, and resolves state-dependent enablement groups from manifest entries.
- Platform-native menu items such as Undo, Redo, Copy, Paste, Select All, Services, Quit, and Window controls stay in Rust because they are OS affordances, not Tolaria app commands.
- Command-palette builders continue to own dynamic labels, filtering, enabled state, and callbacks where those depend on current app state.

View File

@@ -0,0 +1,32 @@
---
type: ADR
id: "0107"
title: "Pointer-owned editor block reordering"
status: active
date: 2026-05-02
---
## Context
Tolaria uses BlockNote for rich Markdown editing, Tauri for native desktop file drops, and custom editor drop handlers for images and wikilinks. BlockNote's default block drag path depends on HTML5 drag events and `DataTransfer`. On macOS inside the Tauri webview, that makes block reordering fragile because the same browser-level drag system also carries native file/image drops into the editor.
Regressions tended to oscillate: fixes that restored block dragging could break image drops, and fixes that protected native drops could make block reordering fail or lose visual feedback. The drag handle also needs editor-specific affordances: a moving block preview, an insertion separator, stale-block protection, and typography-aware positioning for the side-menu controls.
## Decision
**Tolaria owns editor block reordering as a pointer gesture that directly moves BlockNote blocks, and leaves HTML5/native drag data paths for file, image, and external drops.** The drag side menu is responsible for resolving live BlockNote blocks, computing pointer hit targets, rendering drag affordances, and aligning the hover controls to the rendered text range of the hovered block.
## Options considered
- **Pointer-owned block reordering** (chosen): separates internal block moves from native drop payloads, works without `DataTransfer`, gives Tolaria deterministic visual affordances, and can be tested with Playwright pointer/mouse actions. Cons: Tolaria now owns a small amount of hit-testing, block-move, and affordance code around BlockNote.
- **Continue using BlockNote's HTML5 drag handler**: keeps less local code, but ties internal block moves to the same unstable drag channel used by native file drops in Tauri.
- **Patch native file drops around BlockNote drag events**: might repair individual regressions, but preserves the root coupling between editor-internal reordering and external drag payload handling.
- **Disable block dragging on macOS/Tauri**: avoids the conflict, but removes an important editing workflow.
## Consequences
- Internal block reordering must not depend on `DataTransfer` or `draggable=true`.
- File/image/wikilink drop behavior remains owned by the existing editor drop abstractions and native Tauri file-drop bridge.
- Block reordering tests should use pointer or mouse movement in Playwright, and should assert the moving preview and insertion separator while the drag is in progress.
- The side menu should align to measured rendered content rather than heading-specific pixel offsets, so future theme font-size and line-height changes do not need drag-control retuning.
- Changes to BlockNote DOM structure around `.bn-block-content`, `.bn-inline-content`, `.bn-side-menu`, or block container IDs require rechecking the pointer hit-testing and side-menu alignment tests.

View File

@@ -156,3 +156,7 @@ proposed → active → superseded
| [0101](0101-categorical-product-analytics-events.md) | Categorical product analytics events | active |
| [0102](0102-low-end-safe-autosave-idle-window.md) | Low-end-safe autosave idle window | active |
| [0103](0103-adapter-specific-ai-permission-semantics.md) | Adapter-specific AI permission semantics | active |
| [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active |
| [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active |
| [0106](0106-shared-app-command-manifest.md) | Shared app command manifest | active |
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |

View File

@@ -50,6 +50,50 @@
}
})();
</script>
<script>
(function () {
var readyEventName = 'tolaria:frontend-ready';
var reloadAttemptKey = 'tolaria:startup-reload-attempted';
var startupTimeoutMs = 10000;
var isTauri = '__TAURI__' in window || '__TAURI_INTERNALS__' in window;
if (!isTauri) return;
function hasReloadAttempted() {
try {
return sessionStorage.getItem(reloadAttemptKey) === '1';
} catch (err) {
return true;
}
}
function markReloadAttempted() {
try {
sessionStorage.setItem(reloadAttemptKey, '1');
return true;
} catch (err) {
return false;
}
}
function clearReloadAttempt() {
try {
sessionStorage.removeItem(reloadAttemptKey);
} catch (err) {
// Storage can be unavailable in hardened WebView/privacy modes.
}
}
window.addEventListener(readyEventName, clearReloadAttempt, { once: true });
window.setTimeout(function () {
if (window.__tolariaFrontendReady === true) return;
if (hasReloadAttempted()) return;
if (!markReloadAttempted()) return;
window.location.reload();
}, startupTimeoutMs);
})();
</script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -11,7 +11,7 @@ files:
command.openSettings: 638c05f4f159a403e04aebe94b46a2a8
command.openSettings.keywords: ab6b5e03395f4db955ed9cbfd4de33b7
command.openLanguageSettings: e5a425aa6222fab3df66c0e1e0ca4183
command.openLanguageSettings.keywords: 639afefdd1c238b381bc1f963288f389
command.openLanguageSettings.keywords: 6a910000cbf23eac3c7078e518cae66a
command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca
command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
@@ -39,6 +39,7 @@ files:
command.note.newType: adce5108f18945cc502a06c02445e32d
command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2
command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047
command.note.pastePlainText: 09e8075dbd91e11878f8f4a138df760c
command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc
command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1
command.note.deleteNote: 56f727a2ee11d15159f1aa6373314cb6
@@ -384,6 +385,8 @@ files:
inspector.title.properties: 9fc2d28c05ed9eb1d75ba4465abf15a9
inspector.title.propertiesShortcut: 427d1cbdc813cf54c5fac2508d38d407
inspector.title.closePropertiesShortcut: 97b953e45042d4143dd19624dc345d29
inspector.title.collidingProperties: bf71358d0af34696e88bea278eac046a
inspector.title.collidingPropertiesAria: ce5c3569f304cd69b0f33a6fc185f49b
inspector.empty.noNoteSelected: 046d95682b747a30ed8a7b0b1d581629
inspector.empty.noProperties: 6864166e0f65d81b1eb474e41fde8822
inspector.empty.initializeProperties: edf249d214b68f5afe8634c577b709c4
@@ -524,3 +527,4 @@ files:
locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
locale.vi: 7b80fae85640c16cdb0261bef0c27636
locale.plPL: c730389bc8d99e59c867766babdd48b5

View File

@@ -21,6 +21,7 @@ locales:
- zh-TW
- ja-JP
- ko-KR
- pl-PL
files:
json:

View File

@@ -17,7 +17,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",

File diff suppressed because it is too large Load Diff

16
pnpm-lock.yaml generated
View File

@@ -6,7 +6,7 @@ settings:
patchedDependencies:
'@blocknote/core@0.46.2':
hash: 05f1896ec4fac0d7ffd1f823acd036baa82c1d6170a8b5699ddeb6a4dd2b3626
hash: a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76
@@ -27,10 +27,10 @@ importers:
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=05f1896ec4fac0d7ffd1f823acd036baa82c1d6170a8b5699ddeb6a4dd2b3626)(@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=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@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=05f1896ec4fac0d7ffd1f823acd036baa82c1d6170a8b5699ddeb6a4dd2b3626)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@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)
@@ -5359,9 +5359,9 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=05f1896ec4fac0d7ffd1f823acd036baa82c1d6170a8b5699ddeb6a4dd2b3626)(@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=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=05f1896ec4fac0d7ffd1f823acd036baa82c1d6170a8b5699ddeb6a4dd2b3626)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@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
@@ -5369,7 +5369,7 @@ snapshots:
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=05f1896ec4fac0d7ffd1f823acd036baa82c1d6170a8b5699ddeb6a4dd2b3626)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@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)
@@ -5421,7 +5421,7 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=05f1896ec4fac0d7ffd1f823acd036baa82c1d6170a8b5699ddeb6a4dd2b3626)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@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)
@@ -5443,7 +5443,7 @@ snapshots:
'@blocknote/react@0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@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=05f1896ec4fac0d7ffd1f823acd036baa82c1d6170a8b5699ddeb6a4dd2b3626)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10

View File

@@ -12,6 +12,9 @@ const forwardedArgs = process.argv.slice(2)
const hasFileParallelismOverride = forwardedArgs.some((arg) =>
arg === '--fileParallelism' || arg === '--no-file-parallelism'
)
const hasMaxWorkersOverride = forwardedArgs.some((arg) =>
arg === '--maxWorkers' || arg.startsWith('--maxWorkers=')
)
const maxAttempts = 2
const packageManagerExec = process.env.npm_execpath
@@ -46,10 +49,11 @@ async function runCoverageAttempt(attempt) {
const commandArgs = [
...baseCommandArgs,
// Vitest 4.0.18 occasionally crashes during coverage worker teardown
// after all files pass, so serialize file execution unless a caller
// explicitly opts into a different file-parallelism mode.
...(hasFileParallelismOverride ? [] : ['--no-file-parallelism']),
// Keep coverage fast enough for CI while avoiding the unbounded worker
// contention that makes a few DOM-heavy suites time out under full
// file parallelism. Callers can still opt into serial or wider runs.
...(hasFileParallelismOverride ? [] : ['--fileParallelism']),
...(hasMaxWorkersOverride ? [] : ['--maxWorkers=4']),
`--coverage.reportsDirectory=${runCoverageDir}`,
...forwardedArgs,
]

View File

@@ -11,6 +11,9 @@
"core:default",
"core:window:allow-create",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-set-title",
"core:webview:allow-create-webview-window",

View File

@@ -387,6 +387,7 @@ fn build_claude_command(
cwd: Option<&str>,
) -> std::process::Command {
let mut cmd = crate::hidden_command(bin);
crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, bin);
cmd.args(args)
.env_remove("CLAUDECODE") // prevent "nested session" guard
.stdin(Stdio::null())
@@ -1527,7 +1528,7 @@ mod tests {
let mut events = vec![];
let result = run_claude_subprocess(&fake_bin, &[], None, &mut |e| events.push(e));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to spawn"));
assert!(result.unwrap_err().contains("Failed to start claude"));
}
#[cfg(unix)]

View File

@@ -1,5 +1,6 @@
use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent};
use serde::Deserialize;
use std::ffi::OsString;
use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
@@ -39,7 +40,9 @@ pub(crate) fn mcp_server_path_string() -> Result<String, String> {
}
pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
crate::hidden_command(binary)
let mut command = crate::hidden_command(binary);
configure_agent_command_environment(&mut command, binary);
command
.arg("--version")
.output()
.ok()
@@ -47,6 +50,68 @@ pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn configure_agent_command_environment(command: &mut Command, binary: &Path) {
if let Some(path) = expanded_agent_path(binary) {
command.env("PATH", path);
}
}
fn expanded_agent_path(binary: &Path) -> Option<OsString> {
let mut paths = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
.unwrap_or_default();
for candidate in agent_path_candidates(binary) {
push_unique_path(&mut paths, candidate);
}
std::env::join_paths(paths).ok()
}
fn agent_path_candidates(binary: &Path) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if let Some(parent) = binary
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
candidates.push(parent.to_path_buf());
}
if let Some(home) = dirs::home_dir() {
candidates.extend([
home.join(".local/bin"),
home.join(".local/share/mise/shims"),
home.join(".asdf/shims"),
home.join(".npm-global/bin"),
home.join(".npm/bin"),
home.join(".bun/bin"),
home.join(".linuxbrew/bin"),
home.join("AppData/Roaming/npm"),
home.join("AppData/Local/pnpm"),
home.join("scoop/shims"),
]);
}
candidates.extend([
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
]);
candidates
}
fn push_unique_path(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
if candidate.as_os_str().is_empty() {
return;
}
if paths.iter().any(|path| path == &candidate) {
return;
}
paths.push(candidate);
}
pub(crate) fn find_executable_binary_candidate(
candidates: Vec<PathBuf>,
agent_label: &str,
@@ -134,7 +199,7 @@ where
{
let mut child = command
.spawn()
.map_err(|error| format!("Failed to spawn {process_name}: {error}"))?;
.map_err(|error| format_spawn_error(process_name, &error))?;
let stdout = child.stdout.take().ok_or("No stdout handle")?;
let reader = std::io::BufReader::new(stdout);
let mut session_id = String::new();
@@ -166,6 +231,16 @@ where
})
}
fn format_spawn_error(process_name: &str, error: &std::io::Error) -> String {
if error.kind() == std::io::ErrorKind::NotFound {
return format!(
"Failed to start {process_name}: the CLI or one of its runtime dependencies was not found. If it was installed with Homebrew, make sure /opt/homebrew/bin or /usr/local/bin contains the CLI and Node.js, then restart Tolaria. Details: {error}"
);
}
format!("Failed to spawn {process_name}: {error}")
}
pub(crate) fn run_ai_agent_json_stream<F>(
command: Command,
process_name: &'static str,
@@ -229,6 +304,31 @@ mod tests {
assert!(error.contains("broken pipe"));
}
#[test]
fn agent_command_environment_keeps_homebrew_shims_available() {
let mut command = Command::new("/opt/homebrew/bin/codex");
configure_agent_command_environment(&mut command, Path::new("/opt/homebrew/bin/codex"));
let path = command
.get_envs()
.find(|(key, _)| *key == std::ffi::OsStr::new("PATH"))
.and_then(|(_, value)| value)
.expect("PATH should be set");
let paths = std::env::split_paths(path).collect::<Vec<_>>();
assert!(paths.contains(&PathBuf::from("/opt/homebrew/bin")));
assert!(paths.contains(&PathBuf::from("/usr/local/bin")));
}
#[test]
fn spawn_not_found_errors_explain_gui_path_runtime_dependencies() {
let error = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
let message = format_spawn_error("codex", &error);
assert!(message.contains("Failed to start codex"));
assert!(message.contains("/opt/homebrew/bin"));
assert!(message.contains("Node.js"));
}
#[cfg(unix)]
#[test]
fn executable_binary_candidate_skips_unusable_file_when_later_candidate_works() {

View File

@@ -39,13 +39,21 @@ fn find_codex_binary() -> Result<PathBuf, String> {
}
fn find_codex_binary_on_path() -> Option<PathBuf> {
crate::hidden_command("which")
crate::hidden_command(codex_path_lookup_command())
.arg("codex")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn codex_path_lookup_command() -> &'static str {
if cfg!(windows) {
"where"
} else {
"which"
}
}
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
@@ -102,13 +110,27 @@ fn codex_binary_candidates() -> Vec<PathBuf> {
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
let mut candidates = vec![
home.join(".local/bin/codex"),
home.join(".local/bin/codex.exe"),
home.join(".codex/bin/codex"),
home.join(".codex/bin/codex.exe"),
home.join(".local/share/mise/shims/codex"),
home.join(".local/share/mise/shims/codex.exe"),
home.join(".asdf/shims/codex"),
home.join(".asdf/shims/codex.exe"),
home.join(".npm-global/bin/codex"),
home.join(".npm-global/bin/codex.cmd"),
home.join(".npm-global/bin/codex.exe"),
home.join(".npm/bin/codex"),
home.join(".npm/bin/codex.cmd"),
home.join(".npm/bin/codex.exe"),
home.join(".bun/bin/codex"),
home.join(".bun/bin/codex.exe"),
home.join(".linuxbrew/bin/codex"),
home.join("AppData/Roaming/npm/codex.cmd"),
home.join("AppData/Roaming/npm/codex.exe"),
home.join("AppData/Local/pnpm/codex.cmd"),
home.join("AppData/Local/pnpm/codex.exe"),
home.join("scoop/shims/codex.exe"),
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
@@ -149,9 +171,15 @@ fn run_agent_stream_with_binary<F>(
where
F: FnMut(AiAgentStreamEvent),
{
let args = build_codex_args(&request)?;
let last_message_dir = tempfile::Builder::new()
.prefix("tolaria-codex-last-message-")
.tempdir()
.map_err(|error| format!("Failed to create Codex output directory: {error}"))?;
let last_message_path = last_message_dir.path().join("last-message.txt");
let args = build_codex_args(&request, Some(&last_message_path))?;
let prompt = build_codex_prompt(&request);
let command = build_codex_command(binary, args, prompt, &request.vault_path);
let emit = with_codex_last_message_fallback(emit, last_message_path);
crate::cli_agent_runtime::run_ai_agent_json_stream(
command,
@@ -163,6 +191,33 @@ where
)
}
fn with_codex_last_message_fallback<F>(
mut emit: F,
last_message_path: PathBuf,
) -> impl FnMut(AiAgentStreamEvent)
where
F: FnMut(AiAgentStreamEvent),
{
let mut text_emitted = false;
move |event| {
match &event {
AiAgentStreamEvent::TextDelta { text } if !text.trim().is_empty() => {
text_emitted = true;
}
AiAgentStreamEvent::Done if !text_emitted => {
if let Some(text) = read_codex_last_message(&last_message_path) {
text_emitted = true;
emit(AiAgentStreamEvent::TextDelta { text });
}
}
_ => {}
}
emit(event);
}
}
fn build_codex_command(
binary: &Path,
args: Vec<String>,
@@ -170,6 +225,7 @@ fn build_codex_command(
vault_path: &str,
) -> std::process::Command {
let mut command = crate::hidden_command(binary);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
command
.args(args)
.arg(prompt)
@@ -179,10 +235,13 @@ fn build_codex_command(
command
}
fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String> {
fn build_codex_args(
request: &AgentStreamRequest,
last_message_path: Option<&Path>,
) -> Result<Vec<String>, String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
Ok(vec![
let mut args = vec![
"--sandbox".into(),
codex_sandbox(request.permission_mode).into(),
"--ask-for-approval".into(),
@@ -200,7 +259,14 @@ fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String>
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
request.vault_path
),
])
];
if let Some(path) = last_message_path {
args.push("--output-last-message".into());
args.push(path.to_string_lossy().into_owned());
}
Ok(args)
}
fn codex_sandbox(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str {
@@ -270,6 +336,7 @@ where
});
}
}
"mcp_tool_call" => emit_codex_mcp_tool_event(item, item_id, completed, emit),
"agent_message" if completed => {
if let Some(text) = item["text"].as_str() {
emit(AiAgentStreamEvent::TextDelta {
@@ -281,6 +348,56 @@ where
}
}
fn emit_codex_mcp_tool_event<F>(
item: &serde_json::Value,
item_id: &str,
completed: bool,
emit: &mut F,
) where
F: FnMut(AiAgentStreamEvent),
{
if completed {
emit(AiAgentStreamEvent::ToolDone {
tool_id: item_id.to_string(),
output: codex_tool_output(item),
});
return;
}
let tool_name = item["tool"].as_str().unwrap_or("MCP tool");
let input = json_field_to_string(&item["arguments"]);
emit(AiAgentStreamEvent::ToolStart {
tool_name: tool_name.to_string(),
tool_id: item_id.to_string(),
input,
});
}
fn codex_tool_output(item: &serde_json::Value) -> Option<String> {
item["error"]["message"]
.as_str()
.map(|message| format!("Error: {message}"))
.or_else(|| json_field_to_string(&item["result"]))
}
fn json_field_to_string(value: &serde_json::Value) -> Option<String> {
if value.is_null() {
None
} else {
value
.as_str()
.map(str::to_string)
.or_else(|| Some(value.to_string()))
}
}
fn read_codex_last_message(path: &Path) -> Option<String> {
std::fs::read_to_string(path)
.ok()
.map(|text| text.trim().to_string())
.filter(|text| !text.is_empty())
}
fn format_codex_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_codex_auth_error(&lower) {
@@ -398,12 +515,15 @@ mod tests {
#[test]
fn build_codex_args_uses_safe_default_permissions() {
if let Ok(args) = build_codex_args(&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::Safe,
}) {
if let Ok(args) = build_codex_args(
&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::Safe,
},
None,
) {
assert_eq!(args[4], "exec");
assert_codex_permission_contract(&args, AiAgentPermissionMode::Safe);
assert!(args.contains(&"--json".to_string()));
@@ -413,16 +533,38 @@ mod tests {
#[test]
fn codex_power_user_keeps_workspace_write_without_dangerous_bypass() {
if let Ok(args) = build_codex_args(&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::PowerUser,
}) {
if let Ok(args) = build_codex_args(
&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::PowerUser,
},
None,
) {
assert_codex_permission_contract(&args, AiAgentPermissionMode::PowerUser);
}
}
#[test]
fn build_codex_args_can_request_last_message_output_file() {
if let Ok(args) = build_codex_args(
&AgentStreamRequest {
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode: AiAgentPermissionMode::Safe,
},
Some(Path::new("/tmp/tolaria-codex-last-message.txt")),
) {
assert!(args.windows(2).any(|window| window
== [
"--output-last-message",
"/tmp/tolaria-codex-last-message.txt",
]));
}
}
#[test]
fn build_codex_command_keeps_agent_process_contract() {
let binary = PathBuf::from("codex");
@@ -442,6 +584,28 @@ mod tests {
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
}
#[test]
fn build_codex_command_extends_path_with_resolved_homebrew_bin() {
let binary = PathBuf::from("/opt/homebrew/bin/codex");
let command = build_codex_command(
&binary,
vec!["exec".to_string(), "--json".to_string()],
"Summarize".into(),
"/tmp/vault",
);
let path_value = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("PATH"))
.and_then(|(_, value)| value)
.expect("PATH should be set");
let paths = std::env::split_paths(path_value).collect::<Vec<_>>();
assert!(
paths.contains(&PathBuf::from("/opt/homebrew/bin")),
"PATH should include the resolved Codex binary directory, got {paths:?}"
);
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {
@@ -455,6 +619,55 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
assert_codex_text_flow(&events, "thread_1", "Done");
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_uses_last_message_file_when_stream_has_no_text() {
let (thread_id, events) = run_codex_script(
r#"last_message=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--output-last-message" ]; then
shift
last_message="$1"
fi
shift
done
printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s' 'Recovered final answer' > "$last_message"
"#,
);
assert_eq!(thread_id, "thread_1");
assert_codex_text_flow(&events, "thread_1", "Recovered final answer");
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_does_not_duplicate_last_message_file_after_text_event() {
let (thread_id, events) = run_codex_script(
r#"last_message=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--output-last-message" ]; then
shift
last_message="$1"
fi
shift
done
printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Streamed answer"}}'
printf '%s' 'Recovered final answer' > "$last_message"
"#,
);
let text_events = events
.iter()
.filter(|event| matches!(event, AiAgentStreamEvent::TextDelta { .. }))
.count();
assert_eq!(thread_id, "thread_1");
assert_eq!(text_events, 1);
assert_codex_text_flow(&events, "thread_1", "Streamed answer");
}
#[cfg(unix)]
#[test]
fn run_codex_agent_stream_reports_nonzero_exit_errors() {
@@ -514,6 +727,34 @@ exit 2
}
}
#[test]
fn codex_binary_candidates_include_windows_npm_and_toolchain_shims() {
let home = PathBuf::from("C:/Users/alex");
let candidates = codex_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/codex.exe"),
home.join(".local/share/mise/shims/codex.exe"),
home.join(".asdf/shims/codex.exe"),
home.join(".npm-global/bin/codex.cmd"),
home.join(".npm-global/bin/codex.exe"),
home.join(".npm/bin/codex.cmd"),
home.join(".npm/bin/codex.exe"),
home.join("AppData/Roaming/npm/codex.cmd"),
home.join("AppData/Roaming/npm/codex.exe"),
home.join("AppData/Local/pnpm/codex.cmd"),
home.join("AppData/Local/pnpm/codex.exe"),
home.join("scoop/shims/codex.exe"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn codex_binary_candidates_include_nvm_managed_node_installs() {
let home = tempfile::tempdir().unwrap();
@@ -609,6 +850,51 @@ exit 2
));
}
#[test]
fn dispatch_codex_mcp_tool_call_maps_to_tool_events() {
let mut events = Vec::new();
let started = serde_json::json!({
"type": "item.started",
"item": {
"id": "item_1",
"type": "mcp_tool_call",
"server": "tolaria",
"tool": "search_notes",
"arguments": { "query": "meeting", "limit": 5 },
"status": "in_progress"
}
});
let completed = serde_json::json!({
"type": "item.completed",
"item": {
"id": "item_1",
"type": "mcp_tool_call",
"server": "tolaria",
"tool": "search_notes",
"arguments": { "query": "meeting", "limit": 5 },
"result": [{ "title": "Meeting notes" }],
"status": "completed"
}
});
dispatch_codex_event(&started, &mut |event| events.push(event));
dispatch_codex_event(&completed, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
if tool_name == "search_notes"
&& tool_id == "item_1"
&& input.as_deref().is_some_and(|value| value.contains("meeting"))
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "item_1"
&& output.as_deref().is_some_and(|value| value.contains("Meeting notes"))
));
}
#[test]
fn dispatch_codex_agent_message_maps_to_text_delta() {
let mut events = Vec::new();

View File

@@ -214,7 +214,7 @@ fn has_tolaria_vault_marker(path: &std::path::Path) -> bool {
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
validate_git_init_target(&vault_path)?;
crate::git::init_repo(&vault_path)
crate::git::init_repo(std::path::Path::new(vault_path.as_ref()))
}
#[cfg(desktop)]

View File

@@ -0,0 +1,167 @@
use serde::Serialize;
use std::process::Command;
const WEBKIT_AUX_PID_WINDOW: u32 = 512;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessMemoryEntry {
pub pid: u32,
pub parent_pid: u32,
pub rss_bytes: u64,
pub role: String,
pub command: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProcessMemorySnapshot {
pub current_pid: u32,
pub total_rss_bytes: u64,
pub entries: Vec<ProcessMemoryEntry>,
}
struct ProcessRow {
pid: u32,
parent_pid: u32,
rss_kib: u64,
command: String,
}
#[tauri::command]
pub fn get_process_memory_snapshot() -> Result<ProcessMemorySnapshot, String> {
let current_pid = std::process::id();
let entries = collect_related_process_memory(current_pid)?;
let total_rss_bytes = entries.iter().map(|entry| entry.rss_bytes).sum();
Ok(ProcessMemorySnapshot {
current_pid,
total_rss_bytes,
entries,
})
}
fn collect_related_process_memory(current_pid: u32) -> Result<Vec<ProcessMemoryEntry>, String> {
let rows = read_process_rows()?;
Ok(rows
.into_iter()
.filter_map(|row| related_process_entry(row, current_pid))
.collect())
}
fn related_process_entry(row: ProcessRow, current_pid: u32) -> Option<ProcessMemoryEntry> {
let role = classify_related_process(&row, current_pid)?;
Some(ProcessMemoryEntry {
pid: row.pid,
parent_pid: row.parent_pid,
rss_bytes: row.rss_kib.saturating_mul(1024),
role,
command: row.command,
})
}
fn classify_related_process(row: &ProcessRow, current_pid: u32) -> Option<String> {
if row.pid == current_pid {
return Some("app".to_string());
}
if !is_nearby_webkit_auxiliary(row, current_pid) {
return None;
}
if row.command.contains("WebKit.WebContent") {
return Some("webkit-webcontent".to_string());
}
if row.command.contains("WebKit.GPU") {
return Some("webkit-gpu".to_string());
}
if row.command.contains("WebKit.Networking") {
return Some("webkit-networking".to_string());
}
Some("webkit".to_string())
}
fn is_nearby_webkit_auxiliary(row: &ProcessRow, current_pid: u32) -> bool {
row.pid > current_pid
&& row.pid.saturating_sub(current_pid) <= WEBKIT_AUX_PID_WINDOW
&& row.command.contains("com.apple.WebKit.")
}
fn parse_process_row(line: &str) -> Option<ProcessRow> {
let mut fields = line.split_whitespace();
let pid = fields.next()?.parse().ok()?;
let parent_pid = fields.next()?.parse().ok()?;
let rss_kib = fields.next()?.parse().ok()?;
let command = fields.collect::<Vec<_>>().join(" ");
if command.is_empty() {
return None;
}
Some(ProcessRow {
pid,
parent_pid,
rss_kib,
command,
})
}
#[cfg(unix)]
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
let output = Command::new("ps")
.args(["-axo", "pid=,ppid=,rss=,command="])
.output()
.map_err(|error| format!("Failed to sample process memory: {error}"))?;
if !output.status.success() {
return Err("Failed to sample process memory with ps".to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().filter_map(parse_process_row).collect())
}
#[cfg(not(unix))]
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
Err("Process memory snapshots are only implemented on Unix platforms".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_ps_rows_with_spaced_commands() {
let row = parse_process_row(" 42 1 1024 /System/WebKit WebContent").unwrap();
assert_eq!(row.pid, 42);
assert_eq!(row.parent_pid, 1);
assert_eq!(row.rss_kib, 1024);
assert_eq!(row.command, "/System/WebKit WebContent");
}
#[test]
fn classifies_nearby_webkit_auxiliaries() {
let row = ProcessRow {
pid: 120,
parent_pid: 1,
rss_kib: 10,
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
};
assert_eq!(
classify_related_process(&row, 100),
Some("webkit-webcontent".to_string()),
);
}
#[test]
fn ignores_unrelated_webkit_auxiliaries() {
let row = ProcessRow {
pid: 900,
parent_pid: 1,
rss_kib: 10,
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
};
assert_eq!(classify_related_process(&row, 100), None);
}
}

View File

@@ -4,6 +4,7 @@ mod folders;
mod git;
pub mod git_clone;
mod git_connect;
mod memory;
mod system;
mod vault;
mod version;
@@ -15,6 +16,7 @@ pub use delete::*;
pub use folders::*;
pub use git::*;
pub use git_connect::*;
pub use memory::*;
pub use system::*;
pub use vault::*;
pub use version::*;

View File

@@ -147,11 +147,23 @@ fn clipboard_command() -> Command {
crate::hidden_command("pbcopy")
}
#[cfg(target_os = "macos")]
fn clipboard_read_command() -> Command {
crate::hidden_command("pbpaste")
}
#[cfg(target_os = "windows")]
fn clipboard_command() -> Command {
crate::hidden_command("clip.exe")
}
#[cfg(target_os = "windows")]
fn clipboard_read_command() -> Command {
let mut command = crate::hidden_command("powershell.exe");
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
command
}
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
fn clipboard_command() -> Command {
let mut command = crate::hidden_command("sh");
@@ -162,6 +174,16 @@ fn clipboard_command() -> Command {
command
}
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
fn clipboard_read_command() -> Command {
let mut command = crate::hidden_command("sh");
command.args([
"-c",
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
]);
command
}
#[cfg(desktop)]
fn clipboard_failure_message(stderr: &[u8]) -> String {
let message = String::from_utf8_lossy(stderr).trim().to_string();
@@ -200,12 +222,33 @@ fn write_native_clipboard(mut command: Command, text: &str) -> Result<(), String
}
}
#[cfg(desktop)]
fn read_native_clipboard(mut command: Command) -> Result<String, String> {
let output = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(clipboard_failure_message(&output.stderr))
}
}
#[cfg(desktop)]
#[tauri::command]
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
write_native_clipboard(clipboard_command(), &text)
}
#[cfg(desktop)]
#[tauri::command]
pub fn read_text_from_clipboard() -> Result<String, String> {
read_native_clipboard(clipboard_read_command())
}
#[cfg(desktop)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(
@@ -254,6 +297,12 @@ pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
Err("Clipboard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn read_text_from_clipboard() -> Result<String, String> {
Err("Clipboard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {

View File

@@ -271,8 +271,12 @@ pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
}
#[tauri::command]
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
pub async fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
tokio::task::spawn_blocking(move || {
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(test)]
@@ -348,8 +352,8 @@ mod tests {
);
}
#[test]
fn folder_and_listing_commands_use_expanded_vault_root() {
#[tokio::test]
async fn folder_and_listing_commands_use_expanded_vault_root() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
@@ -364,7 +368,7 @@ mod tests {
assert!(entries.iter().any(|entry| entry.filename == "root.md"));
assert!(entries.iter().any(|entry| entry.filename == "project.md"));
let folders = list_vault_folders(root).unwrap();
let folders = list_vault_folders(root).await.unwrap();
assert!(folders.iter().any(|folder| folder.name == "Projects"));
}

View File

@@ -87,7 +87,7 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(&vault_path)?;
git::ensure_gitignore(std::path::Path::new(vault_path.as_ref()))?;
Ok("Vault repaired".to_string())
}

View File

@@ -81,4 +81,14 @@ mod tests {
assert!(list_views(vault_path).unwrap().is_empty());
}
#[test]
fn delete_view_command_treats_missing_backing_file_as_deleted() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_string_lossy().to_string();
delete_view_cmd(vault_path.clone(), "stale-view.yml".to_string()).unwrap();
assert!(list_views(vault_path).unwrap().is_empty());
}
}

View File

@@ -0,0 +1,178 @@
#[derive(Clone, Copy)]
pub(crate) struct FrontmatterKeyRule {
read_key: &'static str,
write_key: &'static str,
aliases: &'static [&'static str],
canonicalize_on_write: bool,
}
#[derive(Clone, Copy)]
pub(crate) struct FrontmatterKey<'a>(&'a str);
impl<'a> FrontmatterKey<'a> {
pub(crate) fn new(key: &'a str) -> Self {
Self(key)
}
pub(crate) fn normalized(self) -> String {
self.0.trim().to_ascii_lowercase().replace(' ', "_")
}
pub(crate) fn is_reserved(self) -> bool {
self.normalized().starts_with('_') || is_known_frontmatter_key(self)
}
}
const KNOWN_FRONTMATTER_KEYS: &[FrontmatterKeyRule] = &[
FrontmatterKeyRule {
read_key: "title",
write_key: "title",
aliases: &["title"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "type",
write_key: "type",
aliases: &["type", "is_a", "Is A"],
canonicalize_on_write: true,
},
FrontmatterKeyRule {
read_key: "aliases",
write_key: "aliases",
aliases: &["aliases"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "_archived",
write_key: "_archived",
aliases: &["_archived", "Archived", "archived"],
canonicalize_on_write: true,
},
FrontmatterKeyRule {
read_key: "Status",
write_key: "Status",
aliases: &["Status", "status"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "_icon",
write_key: "_icon",
aliases: &["_icon", "icon"],
canonicalize_on_write: true,
},
FrontmatterKeyRule {
read_key: "color",
write_key: "color",
aliases: &["color"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "_order",
write_key: "_order",
aliases: &["_order", "order"],
canonicalize_on_write: true,
},
FrontmatterKeyRule {
read_key: "_sidebar_label",
write_key: "_sidebar_label",
aliases: &["_sidebar_label", "sidebar_label", "sidebar label"],
canonicalize_on_write: true,
},
FrontmatterKeyRule {
read_key: "template",
write_key: "template",
aliases: &["template"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "_sort",
write_key: "_sort",
aliases: &["_sort", "sort"],
canonicalize_on_write: true,
},
FrontmatterKeyRule {
read_key: "view",
write_key: "view",
aliases: &["view"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "_width",
write_key: "_width",
aliases: &["_width", "width"],
canonicalize_on_write: true,
},
FrontmatterKeyRule {
read_key: "visible",
write_key: "visible",
aliases: &["visible"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "_organized",
write_key: "_organized",
aliases: &["_organized"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "_favorite",
write_key: "_favorite",
aliases: &["_favorite"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "_favorite_index",
write_key: "_favorite_index",
aliases: &["_favorite_index"],
canonicalize_on_write: false,
},
FrontmatterKeyRule {
read_key: "_list_properties_display",
write_key: "_list_properties_display",
aliases: &["_list_properties_display"],
canonicalize_on_write: false,
},
];
impl FrontmatterKeyRule {
pub(crate) fn read_key(self) -> &'static str {
self.read_key
}
pub(crate) fn write_key(self) -> &'static str {
self.write_key
}
pub(crate) fn canonicalizes_on_write(self) -> bool {
self.canonicalize_on_write
}
fn matches(self, key: FrontmatterKey<'_>) -> bool {
let normalized = key.normalized();
self.aliases
.iter()
.any(|alias| FrontmatterKey::new(alias).normalized() == normalized)
}
}
pub(crate) fn frontmatter_key_rule(key: FrontmatterKey<'_>) -> Option<FrontmatterKeyRule> {
KNOWN_FRONTMATTER_KEYS
.iter()
.copied()
.find(|rule| rule.matches(key))
}
pub(crate) fn canonical_known_frontmatter_key(key: FrontmatterKey<'_>) -> Option<&'static str> {
frontmatter_key_rule(key).map(FrontmatterKeyRule::read_key)
}
pub(crate) fn frontmatter_keys_match(left: FrontmatterKey<'_>, right: FrontmatterKey<'_>) -> bool {
match (frontmatter_key_rule(left), frontmatter_key_rule(right)) {
(Some(left_rule), Some(right_rule)) => left_rule.read_key() == right_rule.read_key(),
_ => left.normalized() == right.normalized(),
}
}
pub(crate) fn is_known_frontmatter_key(key: FrontmatterKey<'_>) -> bool {
frontmatter_key_rule(key).is_some()
}

View File

@@ -1,3 +1,4 @@
pub(crate) mod keys;
mod ops;
#[cfg(test)]
mod ops_update_tests;

View File

@@ -1,3 +1,4 @@
use super::keys::{frontmatter_key_rule, frontmatter_keys_match, FrontmatterKey};
use super::yaml::{format_yaml_field, FrontmatterValue};
/// Check if a line continues the previous key's value (indented list item,
@@ -7,31 +8,9 @@ fn is_value_continuation(line: FrontmatterLine<'_>) -> bool {
}
#[derive(Clone, Copy)]
enum SystemKey {
Icon,
Order,
SidebarLabel,
Sort,
}
impl SystemKey {
fn canonical(self) -> &'static str {
match self {
Self::Icon => "_icon",
Self::Order => "_order",
Self::SidebarLabel => "_sidebar_label",
Self::Sort => "_sort",
}
}
fn legacy_aliases(self) -> &'static [&'static str] {
match self {
Self::Icon => &["icon"],
Self::Order => &["order"],
Self::SidebarLabel => &["sidebar_label", "sidebar label"],
Self::Sort => &["sort"],
}
}
enum KeyMatchMode {
Exact,
Canonical,
}
#[derive(Clone, Copy)]
@@ -47,31 +26,50 @@ impl<'a> PropertyKey<'a> {
fn as_str(self) -> &'a str {
self.0
}
fn matches(self, candidate: &str, mode: KeyMatchMode) -> bool {
match mode {
KeyMatchMode::Exact => candidate == self.as_str(),
KeyMatchMode::Canonical => frontmatter_keys_match(
FrontmatterKey::new(candidate),
FrontmatterKey::new(self.as_str()),
),
}
}
}
impl<'a> FrontmatterLine<'a> {
fn key(self) -> Option<&'a str> {
let trimmed = self.0.trim_start();
if let Some(raw) = trimmed.strip_prefix('"') {
return quoted_yaml_key(raw, '"');
}
if let Some(raw) = trimmed.strip_prefix('\'') {
return quoted_yaml_key(raw, '\'');
}
trimmed
.split_once(':')
.map(|(key, _)| key.trim())
.filter(|key| !key.is_empty())
}
}
fn quoted_yaml_key(raw: &str, quote: char) -> Option<&str> {
let (key, rest) = raw.split_once(quote)?;
rest.trim_start().starts_with(':').then_some(key)
}
#[derive(Clone, Copy)]
struct FieldUpdate<'a> {
key: PropertyKey<'a>,
value: Option<&'a FrontmatterValue>,
match_mode: KeyMatchMode,
}
impl<'a> FieldUpdate<'a> {
fn matches_line(self, line: FrontmatterLine<'_>) -> bool {
let trimmed = line.0.trim_start();
if trimmed.starts_with(self.key.as_str())
&& trimmed[self.key.as_str().len()..].starts_with(':')
{
return true;
}
let double_quoted = format!("\"{}\":", self.key.as_str());
if trimmed.starts_with(&double_quoted) {
return true;
}
let single_quoted = format!("'{}\':", self.key.as_str());
trimmed.starts_with(&single_quoted)
line.key()
.is_some_and(|candidate| self.key.matches(candidate, self.match_mode))
}
fn prepend_to(self, content: DocumentText<'_>) -> String {
@@ -133,22 +131,6 @@ impl<'a> FieldUpdate<'a> {
}
}
fn canonical_system_key(key: PropertyKey<'_>) -> Option<SystemKey> {
match key
.as_str()
.trim()
.to_ascii_lowercase()
.replace(' ', "_")
.as_str()
{
"_icon" | "icon" => Some(SystemKey::Icon),
"_order" | "order" => Some(SystemKey::Order),
"_sidebar_label" | "sidebar_label" | "sidebar label" => Some(SystemKey::SidebarLabel),
"_sort" | "sort" => Some(SystemKey::Sort),
_ => None,
}
}
/// Internal function to update frontmatter content
pub fn update_frontmatter_content(
content: &str,
@@ -158,23 +140,25 @@ pub fn update_frontmatter_content(
let update = FieldUpdate {
key: PropertyKey(key),
value: value.as_ref(),
match_mode: KeyMatchMode::Exact,
};
let Some(system_key) = canonical_system_key(update.key) else {
let Some(rule) = frontmatter_key_rule(FrontmatterKey::new(update.key.as_str()))
.filter(|rule| rule.canonicalizes_on_write())
else {
return update.apply_to_content(DocumentText(content));
};
let mut updated = content.to_string();
for alias in system_key.legacy_aliases() {
updated = FieldUpdate {
key: PropertyKey(alias),
value: None,
}
.apply_to_content(DocumentText(&updated))?;
let updated = FieldUpdate {
key: PropertyKey(rule.write_key()),
value: None,
match_mode: KeyMatchMode::Canonical,
}
.apply_to_content(DocumentText(content))?;
FieldUpdate {
key: PropertyKey(system_key.canonical()),
key: PropertyKey(rule.write_key()),
value: update.value,
match_mode: KeyMatchMode::Exact,
}
.apply_to_content(DocumentText(&updated))
}

View File

@@ -45,8 +45,8 @@ fn test_update_frontmatter_replaces_or_adds_scalar_fields() {
content: "---\n\"Is A\": Note\n---\n# Test\n",
key: "Is A",
value: Some(FrontmatterValue::String("Project".to_string())),
expected_present: &["\"Is A\": Project"],
expected_absent: &["\"Is A\": Note"],
expected_present: &["type: Project"],
expected_absent: &["\"Is A\": Note", "\"Is A\": Project"],
},
UpdateCase {
content: "---\nStatus: Draft\n---\n# Test\n",
@@ -163,6 +163,13 @@ fn test_update_frontmatter_handles_missing_or_malformed_frontmatter() {
#[test]
fn test_update_frontmatter_canonicalizes_system_metadata_keys() {
let cases = [
UpdateCase {
content: "---\narchived: false\n---\n# Test\n",
key: "_archived",
value: Some(FrontmatterValue::Bool(true)),
expected_present: &["_archived: true"],
expected_absent: &["archived: false"],
},
UpdateCase {
content: "---\nicon: rocket\n---\n# Test\n",
key: "icon",
@@ -190,3 +197,41 @@ fn test_update_frontmatter_canonicalizes_system_metadata_keys() {
assert_updated_content(case);
}
}
#[test]
fn test_update_frontmatter_canonicalizes_type_key_case() {
let cases = [
UpdateCase {
content: "---\nType: Note\n---\n# Test\n",
key: "type",
value: Some(FrontmatterValue::String("Project".to_string())),
expected_present: &["type: Project"],
expected_absent: &["Type: Note"],
},
UpdateCase {
content: "---\n\"Is A\": Note\nis_a: Topic\n---\n# Test\n",
key: "type",
value: Some(FrontmatterValue::String("Project".to_string())),
expected_present: &["type: Project"],
expected_absent: &["\"Is A\": Note", "is_a: Topic"],
},
UpdateCase {
content: "---\nTYPE: Note\n---\n# Test\n",
key: "Type",
value: Some(FrontmatterValue::String("Person".to_string())),
expected_present: &["type: Person"],
expected_absent: &["TYPE: Note"],
},
UpdateCase {
content: "---\nType: Note\nstatus: Active\n---\n# Test\n",
key: "type",
value: None,
expected_present: &["status: Active", "# Test"],
expected_absent: &["Type: Note", "\ntype:"],
},
];
for case in cases {
assert_updated_content(case);
}
}

View File

@@ -10,6 +10,7 @@ pub(crate) fn build_command(
) -> Result<std::process::Command, String> {
let settings_path = write_settings(settings_dir, &request.vault_path, request.permission_mode)?;
let mut command = crate::hidden_command(binary);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
command
.args(build_args(request.permission_mode))
.arg("--prompt")
@@ -122,6 +123,28 @@ mod tests {
assert!(settings_dir.path().join("settings.json").exists());
}
#[test]
fn command_extends_path_with_resolved_homebrew_bin() {
let settings_dir = tempfile::tempdir().unwrap();
let command = build_command(
&PathBuf::from("/opt/homebrew/bin/gemini"),
&request(),
settings_dir.path(),
)
.unwrap();
let path_value = command
.get_envs()
.find(|(key, _)| *key == OsStr::new("PATH"))
.and_then(|(_, value)| value)
.expect("PATH should be set");
let paths = std::env::split_paths(path_value).collect::<Vec<_>>();
assert!(
paths.contains(&PathBuf::from("/opt/homebrew/bin")),
"PATH should include the resolved Gemini binary directory, got {paths:?}"
);
}
#[test]
fn safe_settings_include_tolaria_mcp_and_exclude_shell() {
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::Safe).unwrap();

View File

@@ -57,13 +57,45 @@ const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never co
*.swo\n";
fn git_command() -> Command {
crate::hidden_command("git")
let mut command = crate::hidden_command("git");
sanitize_linux_appimage_git_env(&mut command);
command
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
const LINUX_APPIMAGE_GIT_ENV_REMOVALS: [&str; 3] =
["LD_LIBRARY_PATH", "LD_PRELOAD", "GIT_EXEC_PATH"];
#[cfg(all(desktop, target_os = "linux"))]
fn sanitize_linux_appimage_git_env(command: &mut Command) {
sanitize_linux_appimage_git_env_for_launch(command, linux_appimage_env_present());
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn sanitize_linux_appimage_git_env(_command: &mut Command) {}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn sanitize_linux_appimage_git_env_for_launch(command: &mut Command, is_appimage: bool) {
if !is_appimage {
return;
}
for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS {
command.env_remove(key);
}
}
#[cfg(all(desktop, target_os = "linux"))]
fn linux_appimage_env_present() -> bool {
["APPIMAGE", "APPDIR"]
.into_iter()
.any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty()))
}
/// Ensure a `.gitignore` with sensible defaults exists in the vault directory.
/// Creates the file if missing; leaves existing `.gitignore` files untouched.
pub fn ensure_gitignore(path: &str) -> Result<(), String> {
let gitignore_path = Path::new(path).join(".gitignore");
pub fn ensure_gitignore(path: impl AsRef<Path>) -> Result<(), String> {
let gitignore_path = path.as_ref().join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(&gitignore_path, DEFAULT_GITIGNORE)
.map_err(|e| format!("Failed to write .gitignore: {}", e))?;
@@ -72,15 +104,15 @@ pub fn ensure_gitignore(path: &str) -> Result<(), String> {
}
/// Initialize a new git repository, stage all files, and create an initial commit.
pub fn init_repo(path: &str) -> Result<(), String> {
let dir = Path::new(path);
pub fn init_repo(path: impl AsRef<Path>) -> Result<(), String> {
let dir = path.as_ref();
run_git(dir, &["init"])?;
ensure_author_config(dir)?;
// Write .gitignore before the first commit so machine-specific and
// macOS metadata files are never tracked and don't cause conflicts.
ensure_gitignore(path)?;
ensure_gitignore(dir)?;
run_git(dir, &["add", "."])?;
commit_initial_vault_setup(dir)?;
@@ -176,6 +208,7 @@ fn parse_github_repo_path(url: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
@@ -259,6 +292,18 @@ mod tests {
(bare_dir, clone_a_dir, clone_b_dir)
}
fn command_envs(command: &Command) -> 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 test_ensure_gitignore_creates_file() {
let dir = TempDir::new().unwrap();
@@ -282,6 +327,32 @@ mod tests {
assert_eq!(content, "my-rule\n");
}
#[test]
fn test_linux_appimage_git_commands_remove_appimage_loader_env() {
let mut command = crate::hidden_command("git");
sanitize_linux_appimage_git_env_for_launch(&mut command, true);
let envs = command_envs(&command);
for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS {
assert_eq!(envs.get(key), Some(&None));
}
}
#[test]
fn test_non_appimage_git_commands_keep_parent_env_unmodified() {
let mut command = crate::hidden_command("git");
sanitize_linux_appimage_git_env_for_launch(&mut command, false);
let envs = command_envs(&command);
for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS {
assert!(!envs.contains_key(key));
}
}
#[test]
fn test_init_repo_creates_git_directory() {
let dir = TempDir::new().unwrap();

View File

@@ -9,6 +9,8 @@ pub mod gemini_cli;
mod gemini_config;
mod gemini_discovery;
pub mod git;
#[cfg(any(test, all(desktop, target_os = "linux")))]
mod linux_appimage;
pub mod mcp;
#[cfg(desktop)]
pub mod menu;
@@ -32,8 +34,6 @@ mod window_state;
use std::ffi::OsStr;
use std::process::Command;
#[cfg(all(desktop, target_os = "linux"))]
use std::os::unix::process::CommandExt;
#[cfg(desktop)]
use std::path::{Path, PathBuf};
#[cfg(desktop)]
@@ -65,136 +65,6 @@ struct WsBridgeChild(Mutex<Option<Child>>);
#[cfg(desktop)]
struct AllowedAssetScopeRoots(Mutex<Vec<PathBuf>>);
#[cfg(any(test, all(desktop, target_os = "linux")))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StartupEnvOverride {
key: &'static str,
value: &'static str,
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
const LINUX_APPIMAGE_WEBKIT_OVERRIDES: [StartupEnvOverride; 2] = [
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
},
];
#[cfg(any(test, all(desktop, target_os = "linux")))]
const WAYLAND_CLIENT_PRELOAD_CANDIDATES: [&str; 7] = [
"/usr/lib/libwayland-client.so",
"/usr/lib/libwayland-client.so.0",
"/usr/lib64/libwayland-client.so",
"/usr/lib64/libwayland-client.so.0",
"/lib64/libwayland-client.so.0",
"/lib/x86_64-linux-gnu/libwayland-client.so.0",
"/usr/lib/x86_64-linux-gnu/libwayland-client.so.0",
];
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn is_linux_appimage_launch<F>(mut get_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
["APPIMAGE", "APPDIR"]
.into_iter()
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn is_wayland_session<F>(mut get_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
get_var("WAYLAND_DISPLAY").is_some_and(|value| !value.trim().is_empty())
|| get_var("XDG_SESSION_TYPE")
.is_some_and(|value| value.trim().eq_ignore_ascii_case("wayland"))
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn linux_appimage_wayland_client_preload_path_with<F, E>(
mut get_var: F,
mut file_exists: E,
) -> Option<&'static str>
where
F: FnMut(&str) -> Option<String>,
E: FnMut(&str) -> bool,
{
if !is_linux_appimage_launch(&mut get_var) || !is_wayland_session(&mut get_var) {
return None;
}
if get_var("LD_PRELOAD").is_some_and(|value| !value.trim().is_empty())
|| get_var("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED").is_some_and(|value| value == "1")
{
return None;
}
WAYLAND_CLIENT_PRELOAD_CANDIDATES
.into_iter()
.find(|path| file_exists(path))
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn linux_appimage_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) {
return Vec::new();
}
LINUX_APPIMAGE_WEBKIT_OVERRIDES
.into_iter()
.filter(|env_override| {
!get_var(env_override.key).is_some_and(|value| !value.trim().is_empty())
})
.collect()
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_linux_appimage_startup_env_overrides() {
apply_linux_appimage_wayland_client_preload();
for env_override in linux_appimage_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_linux_appimage_wayland_client_preload() {
let Some(preload_path) = linux_appimage_wayland_client_preload_path_with(
|key| std::env::var(key).ok(),
|path| std::path::Path::new(path).is_file(),
) else {
return;
};
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(e) => {
eprintln!(
"Tolaria AppImage Wayland preload skipped: failed to resolve executable ({e})"
);
return;
}
};
let error = Command::new(exe)
.args(std::env::args_os().skip(1))
.env("LD_PRELOAD", preload_path)
.env("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED", "1")
.exec();
eprintln!("Tolaria AppImage Wayland preload skipped: failed to re-exec ({error})");
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn apply_linux_appimage_startup_env_overrides() {}
#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
@@ -579,7 +449,9 @@ macro_rules! app_invoke_handler {
commands::check_mcp_status,
commands::get_mcp_config_snippet,
commands::copy_text_to_clipboard,
commands::read_text_from_clipboard,
commands::sync_mcp_bridge_vault,
commands::get_process_memory_snapshot,
commands::repair_vault,
commands::reinit_telemetry,
commands::list_views,
@@ -610,7 +482,9 @@ fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
apply_linux_appimage_startup_env_overrides();
#[cfg(all(desktop, target_os = "linux"))]
linux_appimage::apply_startup_env_overrides();
let builder = tauri::Builder::default();
#[cfg(desktop)]
@@ -632,9 +506,6 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::linux_appimage_startup_env_overrides_with;
use super::linux_appimage_wayland_client_preload_path_with;
use super::StartupEnvOverride;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
@@ -657,98 +528,6 @@ mod tests {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
#[test]
fn linux_appimage_startup_env_overrides_are_empty_outside_appimage_launches() {
let overrides = linux_appimage_startup_env_overrides_with(|_| None);
assert!(overrides.is_empty());
}
#[test]
fn linux_appimage_startup_env_overrides_disable_unstable_webkit_rendering_for_appimages() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}
]
);
}
#[test]
fn linux_appimage_startup_env_overrides_preserve_explicit_user_setting_per_variable() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}]
);
}
#[test]
fn linux_appimage_wayland_preload_uses_first_available_system_library() {
let preload_path = linux_appimage_wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
_ => None,
},
|path| path == "/lib/x86_64-linux-gnu/libwayland-client.so.0",
);
assert_eq!(
preload_path,
Some("/lib/x86_64-linux-gnu/libwayland-client.so.0")
);
}
#[test]
fn linux_appimage_wayland_preload_preserves_explicit_ld_preload() {
let preload_path = linux_appimage_wayland_client_preload_path_with(
|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WAYLAND_DISPLAY" => Some("wayland-0".to_string()),
"LD_PRELOAD" => Some("/custom/libwayland-client.so".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
#[test]
fn linux_appimage_wayland_preload_is_empty_for_x11_sessions() {
let preload_path = linux_appimage_wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("x11".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_path_uses_persisted_active_vault() {

View File

@@ -0,0 +1,284 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StartupEnvOverride {
key: &'static str,
value: &'static str,
}
const LINUX_APPIMAGE_WEBKIT_OVERRIDES: [StartupEnvOverride; 2] = [
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
},
];
const WAYLAND_CLIENT_PRELOAD_CANDIDATES: [&str; 7] = [
"/usr/lib64/libwayland-client.so.0",
"/usr/lib64/libwayland-client.so",
"/lib64/libwayland-client.so.0",
"/usr/lib/x86_64-linux-gnu/libwayland-client.so.0",
"/lib/x86_64-linux-gnu/libwayland-client.so.0",
"/usr/lib/libwayland-client.so.0",
"/usr/lib/libwayland-client.so",
];
#[cfg(target_pointer_width = "64")]
const PROCESS_ELF_CLASS: u8 = 2;
#[cfg(target_pointer_width = "32")]
const PROCESS_ELF_CLASS: u8 = 1;
fn is_linux_appimage_launch<F>(mut get_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
["APPIMAGE", "APPDIR"]
.into_iter()
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
}
fn is_wayland_session<F>(mut get_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
get_var("WAYLAND_DISPLAY").is_some_and(|value| !value.trim().is_empty())
|| get_var("XDG_SESSION_TYPE")
.is_some_and(|value| value.trim().eq_ignore_ascii_case("wayland"))
}
fn elf_library_matches_process(path: &std::path::Path) -> bool {
let Ok(mut file) = std::fs::File::open(path) else {
return false;
};
let mut header = [0; 5];
if std::io::Read::read_exact(&mut file, &mut header).is_err() {
return false;
}
header[..4] == *b"\x7FELF" && header[4] == PROCESS_ELF_CLASS
}
#[cfg(all(desktop, target_os = "linux"))]
fn wayland_preload_candidate_matches(path: &str) -> bool {
let path = std::path::Path::new(path);
path.is_file() && elf_library_matches_process(path)
}
fn wayland_client_preload_path_with<F, E>(
mut get_var: F,
mut candidate_matches: E,
) -> Option<&'static str>
where
F: FnMut(&str) -> Option<String>,
E: FnMut(&str) -> bool,
{
if !is_linux_appimage_launch(&mut get_var) || !is_wayland_session(&mut get_var) {
return None;
}
if get_var("LD_PRELOAD").is_some_and(|value| !value.trim().is_empty())
|| get_var("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED").is_some_and(|value| value == "1")
{
return None;
}
WAYLAND_CLIENT_PRELOAD_CANDIDATES
.into_iter()
.find(|path| candidate_matches(path))
}
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) {
return Vec::new();
}
LINUX_APPIMAGE_WEBKIT_OVERRIDES
.into_iter()
.filter(|env_override| {
!get_var(env_override.key).is_some_and(|value| !value.trim().is_empty())
})
.collect()
}
#[cfg(all(desktop, target_os = "linux"))]
pub(crate) fn apply_startup_env_overrides() {
apply_wayland_client_preload();
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_wayland_client_preload() {
use std::os::unix::process::CommandExt;
let Some(preload_path) = wayland_client_preload_path_with(
|key| std::env::var(key).ok(),
wayland_preload_candidate_matches,
) else {
return;
};
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(e) => {
eprintln!(
"Tolaria AppImage Wayland preload skipped: failed to resolve executable ({e})"
);
return;
}
};
let error = std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.env("LD_PRELOAD", preload_path)
.env("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED", "1")
.exec();
eprintln!("Tolaria AppImage Wayland preload skipped: failed to re-exec ({error})");
}
#[cfg(test)]
mod tests {
use super::{
elf_library_matches_process, startup_env_overrides_with, wayland_client_preload_path_with,
StartupEnvOverride,
};
#[test]
fn startup_env_overrides_are_empty_outside_appimage_launches() {
let overrides = startup_env_overrides_with(|_| None);
assert!(overrides.is_empty());
}
#[test]
fn startup_env_overrides_disable_unstable_webkit_rendering_for_appimages() {
let overrides = startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}
]
);
}
#[test]
fn startup_env_overrides_preserve_explicit_user_setting_per_variable() {
let overrides = startup_env_overrides_with(|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}]
);
}
#[test]
fn wayland_preload_uses_first_available_system_library() {
let preload_path = wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
_ => None,
},
|path| path == "/lib/x86_64-linux-gnu/libwayland-client.so.0",
);
assert_eq!(
preload_path,
Some("/lib/x86_64-linux-gnu/libwayland-client.so.0")
);
}
#[test]
fn wayland_preload_prefers_fedora_lib64_over_usr_lib() {
let preload_path = wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
_ => None,
},
|path| {
path == "/usr/lib/libwayland-client.so.0"
|| path == "/usr/lib64/libwayland-client.so.0"
},
);
assert_eq!(preload_path, Some("/usr/lib64/libwayland-client.so.0"));
}
#[test]
fn preload_library_rejects_wrong_elf_class() {
let dir = tempfile::tempdir().unwrap();
let matching = dir.path().join("matching-libwayland-client.so.0");
let mismatched = dir.path().join("mismatched-libwayland-client.so.0");
let matching_class = if cfg!(target_pointer_width = "64") {
2
} else {
1
};
let mismatched_class = if matching_class == 2 { 1 } else { 2 };
std::fs::write(&matching, [0x7F, b'E', b'L', b'F', matching_class]).unwrap();
std::fs::write(&mismatched, [0x7F, b'E', b'L', b'F', mismatched_class]).unwrap();
assert!(elf_library_matches_process(&matching));
assert!(!elf_library_matches_process(&mismatched));
assert!(!elf_library_matches_process(&dir.path().join("missing.so")));
}
#[test]
fn wayland_preload_preserves_explicit_ld_preload() {
let preload_path = wayland_client_preload_path_with(
|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WAYLAND_DISPLAY" => Some("wayland-0".to_string()),
"LD_PRELOAD" => Some("/custom/libwayland-client.so".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
#[test]
fn wayland_preload_is_empty_for_x11_sessions() {
let preload_path = wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("x11".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
}

View File

@@ -1,161 +1,289 @@
use serde::{Deserialize, Deserializer};
use std::{
collections::{BTreeMap, HashSet},
error::Error,
sync::OnceLock,
};
use tauri::{
menu::{MenuBuilder, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder},
menu::{MenuBuilder, MenuItem, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder},
App, AppHandle, Emitter,
};
// Custom menu item IDs that emit events to the frontend.
const APP_SETTINGS: &str = "app-settings";
const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
const APP_COMMAND_MANIFEST_JSON: &str = include_str!("../../src/shared/appCommandManifest.json");
const NOTE_DEPENDENT_GROUP: &str = "noteDependent";
const EDITOR_FIND_DEPENDENT_GROUP: &str = "editorFindDependent";
const NOTE_LIST_SEARCH_DEPENDENT_GROUP: &str = "noteListSearchDependent";
const RESTORE_DELETED_DEPENDENT_GROUP: &str = "restoreDeletedDependent";
const GIT_COMMIT_DEPENDENT_GROUP: &str = "gitCommitDependent";
const GIT_CONFLICT_DEPENDENT_GROUP: &str = "gitConflictDependent";
const GIT_NO_REMOTE_DEPENDENT_GROUP: &str = "gitNoRemoteDependent";
const FILE_NEW_NOTE: &str = "file-new-note";
const FILE_NEW_TYPE: &str = "file-new-type";
const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
const FILE_SAVE: &str = "file-save";
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn Error>>;
type AppSubmenuBuilder<'a> = SubmenuBuilder<'a, tauri::Wry, App>;
const EDIT_FIND_IN_NOTE: &str = "edit-find-in-note";
const EDIT_REPLACE_IN_NOTE: &str = "edit-replace-in-note";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search";
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AppCommandManifest {
commands: BTreeMap<String, ManifestCommand>,
menus: Vec<ManifestMenuSection>,
app_menu: Vec<ManifestMenuItem>,
menu_state_groups: BTreeMap<String, Vec<MenuStateGroupReference>>,
}
const VIEW_EDITOR_ONLY: &str = "view-editor-only";
const VIEW_EDITOR_LIST: &str = "view-editor-list";
const VIEW_ALL: &str = "view-all";
const VIEW_TOGGLE_PROPERTIES: &str = "view-toggle-properties";
const VIEW_TOGGLE_AI_CHAT: &str = "view-toggle-ai-chat";
const VIEW_TOGGLE_BACKLINKS: &str = "view-toggle-backlinks";
const VIEW_COMMAND_PALETTE: &str = "view-command-palette";
const VIEW_ZOOM_IN: &str = "view-zoom-in";
const VIEW_ZOOM_OUT: &str = "view-zoom-out";
const VIEW_ZOOM_RESET: &str = "view-zoom-reset";
const VIEW_GO_BACK: &str = "view-go-back";
const VIEW_GO_FORWARD: &str = "view-go-forward";
#[derive(Debug, Deserialize)]
struct ManifestCommand {
id: String,
shortcut: Option<ManifestShortcut>,
}
const GO_ALL_NOTES: &str = "go-all-notes";
const GO_ARCHIVED: &str = "go-archived";
const GO_CHANGES: &str = "go-changes";
const GO_INBOX: &str = "go-inbox";
#[derive(Debug, Deserialize)]
struct ManifestShortcut {
accelerator: String,
}
const NOTE_TOGGLE_ORGANIZED: &str = "note-toggle-organized";
const NOTE_ARCHIVE: &str = "note-archive";
const NOTE_DELETE: &str = "note-delete";
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
const NOTE_RESTORE_DELETED: &str = "note-restore-deleted";
#[derive(Debug, Deserialize)]
struct ManifestMenuSection {
label: String,
items: Vec<ManifestMenuItem>,
}
const VAULT_OPEN: &str = "vault-open";
const VAULT_REMOVE: &str = "vault-remove";
const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started";
const VAULT_ADD_REMOTE: &str = "vault-add-remote";
const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
const VAULT_PULL: &str = "vault-pull";
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
const VAULT_RELOAD: &str = "vault-reload";
const VAULT_REPAIR: &str = "vault-repair";
#[derive(Debug, Deserialize)]
#[serde(tag = "kind")]
enum ManifestMenuItem {
#[serde(rename = "separator")]
Separator,
#[serde(rename = "command")]
Command {
command: String,
id: Option<String>,
label: PlatformLabel,
#[serde(default, deserialize_with = "deserialize_accelerator")]
accelerator: ManifestAccelerator,
enabled: Option<bool>,
},
#[serde(rename = "menu-event")]
MenuEvent {
id: String,
label: PlatformLabel,
#[serde(default, deserialize_with = "deserialize_accelerator")]
accelerator: ManifestAccelerator,
enabled: Option<bool>,
},
}
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
APP_CHECK_FOR_UPDATES,
FILE_NEW_NOTE,
FILE_NEW_TYPE,
FILE_QUICK_OPEN,
FILE_QUICK_OPEN_ALIAS,
FILE_SAVE,
EDIT_FIND_IN_NOTE,
EDIT_REPLACE_IN_NOTE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_NOTE_LIST_SEARCH,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
VIEW_EDITOR_LIST,
VIEW_ALL,
VIEW_TOGGLE_PROPERTIES,
VIEW_TOGGLE_AI_CHAT,
VIEW_TOGGLE_BACKLINKS,
VIEW_COMMAND_PALETTE,
VIEW_ZOOM_IN,
VIEW_ZOOM_OUT,
VIEW_ZOOM_RESET,
VIEW_GO_BACK,
VIEW_GO_FORWARD,
GO_ALL_NOTES,
GO_ARCHIVED,
GO_CHANGES,
GO_INBOX,
NOTE_TOGGLE_ORGANIZED,
NOTE_ARCHIVE,
NOTE_DELETE,
NOTE_OPEN_IN_NEW_WINDOW,
NOTE_RESTORE_DELETED,
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_ADD_REMOTE,
VAULT_COMMIT_PUSH,
VAULT_PULL,
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_RELOAD,
VAULT_REPAIR,
];
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PlatformLabel {
Plain(String),
Platform {
macos: Option<String>,
windows: Option<String>,
linux: Option<String>,
default: String,
},
}
/// IDs of menu items that should be disabled when no note tab is active.
const NOTE_DEPENDENT_IDS: &[&str] = &[
FILE_SAVE,
NOTE_TOGGLE_ORGANIZED,
NOTE_ARCHIVE,
NOTE_DELETE,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_TOGGLE_BACKLINKS,
NOTE_OPEN_IN_NEW_WINDOW,
];
#[derive(Debug, Default)]
enum ManifestAccelerator {
#[default]
Inherit,
Suppressed,
Explicit(String),
}
/// IDs of menu items that depend on the editor being the active surface.
const EDITOR_FIND_DEPENDENT_IDS: &[&str] = &[EDIT_FIND_IN_NOTE, EDIT_REPLACE_IN_NOTE];
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum MenuStateGroupReference {
Command { command: String },
Id { id: String },
}
/// IDs of menu items that depend on the note list being the active surface.
const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH];
fn deserialize_accelerator<'de, D>(deserializer: D) -> Result<ManifestAccelerator, D::Error>
where
D: Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(|accelerator| match accelerator {
Some(accelerator) => ManifestAccelerator::Explicit(accelerator),
None => ManifestAccelerator::Suppressed,
})
}
/// IDs of menu items that depend on a deleted-note preview being active.
const RESTORE_DELETED_DEPENDENT_IDS: &[&str] = &[NOTE_RESTORE_DELETED];
impl PlatformLabel {
fn resolve(&self, target_os: &str) -> &str {
match self {
Self::Plain(label) => label.as_str(),
Self::Platform {
macos,
windows,
linux,
default,
} => match target_os {
"macos" => macos.as_deref().unwrap_or(default.as_str()),
"windows" => windows.as_deref().unwrap_or(default.as_str()),
"linux" => linux.as_deref().unwrap_or(default.as_str()),
_ => default.as_str(),
},
}
}
}
/// IDs of menu items that depend on having uncommitted changes.
const GIT_COMMIT_DEPENDENT_IDS: &[&str] = &[VAULT_COMMIT_PUSH];
impl ManifestMenuItem {
fn command_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> {
match self {
Self::Command { command, .. } => manifest
.commands
.get(command)
.map(|command| command.id.as_str()),
Self::MenuEvent { id, .. } => Some(id.as_str()),
Self::Separator => None,
}
}
/// IDs of menu items that depend on having merge conflicts.
const GIT_CONFLICT_DEPENDENT_IDS: &[&str] = &[VAULT_RESOLVE_CONFLICTS];
fn menu_item_id<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> {
match self {
Self::Command { command, id, .. } => id.as_deref().or_else(|| {
manifest
.commands
.get(command)
.map(|command| command.id.as_str())
}),
Self::MenuEvent { id, .. } => Some(id.as_str()),
Self::Separator => None,
}
}
/// IDs of menu items that depend on the active vault having no remote configured.
const GIT_NO_REMOTE_DEPENDENT_IDS: &[&str] = &[VAULT_ADD_REMOTE];
fn label(&self, target_os: &str) -> Option<&str> {
match self {
Self::Command { label, .. } | Self::MenuEvent { label, .. } => {
Some(label.resolve(target_os))
}
Self::Separator => None,
}
}
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
fn accelerator<'a>(&'a self, manifest: &'a AppCommandManifest) -> Option<&'a str> {
match self {
Self::Command {
command,
accelerator,
..
} => match accelerator {
ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()),
ManifestAccelerator::Suppressed => None,
ManifestAccelerator::Inherit => manifest
.commands
.get(command)
.and_then(|command| command.shortcut.as_ref())
.map(|shortcut| shortcut.accelerator.as_str()),
},
Self::MenuEvent { accelerator, .. } => match accelerator {
ManifestAccelerator::Explicit(accelerator) => Some(accelerator.as_str()),
ManifestAccelerator::Suppressed | ManifestAccelerator::Inherit => None,
},
Self::Separator => None,
}
}
fn enabled(&self) -> bool {
match self {
Self::Command { enabled, .. } | Self::MenuEvent { enabled, .. } => {
enabled.unwrap_or(true)
}
Self::Separator => true,
}
}
}
static APP_COMMAND_MANIFEST: OnceLock<AppCommandManifest> = OnceLock::new();
static CUSTOM_MENU_IDS: OnceLock<HashSet<String>> = OnceLock::new();
fn manifest() -> &'static AppCommandManifest {
APP_COMMAND_MANIFEST.get_or_init(|| {
serde_json::from_str(APP_COMMAND_MANIFEST_JSON)
.expect("shared app command manifest must be valid JSON")
})
}
fn manifest_menu_items() -> impl Iterator<Item = &'static ManifestMenuItem> {
let manifest = manifest();
manifest
.menus
.iter()
.flat_map(|section| section.items.iter())
.chain(manifest.app_menu.iter())
}
fn custom_menu_ids() -> &'static HashSet<String> {
CUSTOM_MENU_IDS.get_or_init(|| {
manifest_menu_items()
.filter_map(|item| item.menu_item_id(manifest()))
.map(str::to_owned)
.collect()
})
}
fn manifest_section(label: &str) -> Result<&'static ManifestMenuSection, Box<dyn Error>> {
manifest()
.menus
.iter()
.find(|section| section.label == label)
.ok_or_else(|| format!("Missing menu section in command manifest: {label}").into())
}
fn app_menu_includes_services(target_os: &str) -> bool {
target_os == "macos"
}
fn build_app_menu(app: &App) -> MenuResult {
let settings_item = MenuItemBuilder::new("Settings...")
.id(APP_SETTINGS)
.accelerator("CmdOrCtrl+,")
.build(app)?;
let check_updates_item = MenuItemBuilder::new("Check for Updates...")
.id(APP_CHECK_FOR_UPDATES)
.build(app)?;
fn build_manifest_menu_item(
app: &App,
item: &ManifestMenuItem,
) -> Result<Option<MenuItem<tauri::Wry>>, Box<dyn Error>> {
let Some(id) = item.menu_item_id(manifest()) else {
return Ok(None);
};
let Some(label) = item.label(std::env::consts::OS) else {
return Ok(None);
};
let mut builder = SubmenuBuilder::new(app, "Tolaria")
.about(None)
.separator()
.item(&check_updates_item)
.separator()
.item(&settings_item)
.separator();
let mut builder = MenuItemBuilder::new(label).id(id).enabled(item.enabled());
if let Some(accelerator) = item.accelerator(manifest()) {
builder = builder.accelerator(accelerator);
}
Ok(Some(builder.build(app)?))
}
fn append_manifest_item<'a>(
app: &'a App,
builder: AppSubmenuBuilder<'a>,
item: &ManifestMenuItem,
) -> Result<AppSubmenuBuilder<'a>, Box<dyn Error>> {
if matches!(item, ManifestMenuItem::Separator) {
return Ok(builder.separator());
}
let Some(item) = build_manifest_menu_item(app, item)? else {
return Ok(builder);
};
Ok(builder.item(&item))
}
fn build_manifest_menu(app: &App, label: &str) -> MenuResult {
let section = manifest_section(label)?;
let mut builder = SubmenuBuilder::new(app, section.label.as_str());
for item in &section.items {
builder = append_manifest_item(app, builder, item)?;
}
Ok(builder.build()?)
}
fn build_app_menu(app: &App) -> MenuResult {
let mut builder = SubmenuBuilder::new(app, "Tolaria").about(None).separator();
for item in &manifest().app_menu {
builder = append_manifest_item(app, builder, item)?;
}
builder = builder.separator();
if app_menu_includes_services(std::env::consts::OS) {
builder = builder
@@ -171,258 +299,51 @@ fn build_app_menu(app: &App) -> MenuResult {
}
fn build_file_menu(app: &App) -> MenuResult {
let quick_open_alias_label = if cfg!(target_os = "macos") {
"Quick Open (Cmd+O)"
} else {
"Quick Open (Ctrl+O)"
};
let new_note = MenuItemBuilder::new("New Note")
.id(FILE_NEW_NOTE)
.accelerator("CmdOrCtrl+N")
.build(app)?;
let new_type = MenuItemBuilder::new("New Type")
.id(FILE_NEW_TYPE)
.build(app)?;
let quick_open = MenuItemBuilder::new("Quick Open")
.id(FILE_QUICK_OPEN)
.accelerator("CmdOrCtrl+P")
.build(app)?;
let quick_open_alias = MenuItemBuilder::new(quick_open_alias_label)
.id(FILE_QUICK_OPEN_ALIAS)
.accelerator("CmdOrCtrl+O")
.build(app)?;
let save = MenuItemBuilder::new("Save")
.id(FILE_SAVE)
.accelerator("CmdOrCtrl+S")
.build(app)?;
Ok(SubmenuBuilder::new(app, "File")
.item(&new_note)
.item(&new_type)
.item(&quick_open)
.item(&quick_open_alias)
.separator()
.item(&save)
.build()?)
build_manifest_menu(app, "File")
}
fn build_edit_menu(app: &App) -> MenuResult {
let find_in_note = MenuItemBuilder::new("Find in Note")
.id(EDIT_FIND_IN_NOTE)
.accelerator("CmdOrCtrl+F")
.enabled(false)
.build(app)?;
let replace_in_note = MenuItemBuilder::new("Replace in Note")
.id(EDIT_REPLACE_IN_NOTE)
.enabled(false)
.build(app)?;
let find_in_vault = MenuItemBuilder::new("Find in Vault")
.id(EDIT_FIND_IN_VAULT)
.accelerator("CmdOrCtrl+Shift+F")
.build(app)?;
let toggle_note_list_search = MenuItemBuilder::new("Toggle Note List Search")
.id(EDIT_TOGGLE_NOTE_LIST_SEARCH)
.accelerator("CmdOrCtrl+F")
.enabled(false)
.build(app)?;
let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode")
.id(EDIT_TOGGLE_DIFF)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Edit")
let section = manifest_section("Edit")?;
let mut items = section.items.iter();
let mut builder = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.separator()
.select_all()
.separator()
.item(&find_in_note)
.item(&replace_in_note)
.item(&find_in_vault)
.item(&toggle_note_list_search)
.item(&toggle_diff)
.build()?)
.paste();
if let Some(paste_plain_text) = items.next() {
builder = append_manifest_item(app, builder, paste_plain_text)?;
}
builder = builder.separator().select_all().separator();
if matches!(items.clone().next(), Some(ManifestMenuItem::Separator)) {
items.next();
}
for item in items {
builder = append_manifest_item(app, builder, item)?;
}
Ok(builder.build()?)
}
fn build_view_menu(app: &App) -> MenuResult {
let editor_only = MenuItemBuilder::new("Editor Only")
.id(VIEW_EDITOR_ONLY)
.accelerator("CmdOrCtrl+1")
.build(app)?;
let editor_list = MenuItemBuilder::new("Editor + Notes")
.id(VIEW_EDITOR_LIST)
.accelerator("CmdOrCtrl+2")
.build(app)?;
let all_panels = MenuItemBuilder::new("All Panels")
.id(VIEW_ALL)
.accelerator("CmdOrCtrl+3")
.build(app)?;
// Keep Cmd+Shift+I on the renderer path. The menu item stays available,
// but the native accelerator has proven unreliable for this command.
let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel")
.id(VIEW_TOGGLE_PROPERTIES)
.build(app)?;
let command_palette = MenuItemBuilder::new("Command Palette")
.id(VIEW_COMMAND_PALETTE)
.accelerator("CmdOrCtrl+K")
.build(app)?;
let zoom_in = MenuItemBuilder::new("Zoom In")
.id(VIEW_ZOOM_IN)
.accelerator("CmdOrCtrl+=")
.build(app)?;
let zoom_out = MenuItemBuilder::new("Zoom Out")
.id(VIEW_ZOOM_OUT)
.accelerator("CmdOrCtrl+-")
.build(app)?;
let zoom_reset = MenuItemBuilder::new("Actual Size")
.id(VIEW_ZOOM_RESET)
.accelerator("CmdOrCtrl+0")
.build(app)?;
Ok(SubmenuBuilder::new(app, "View")
.item(&editor_only)
.item(&editor_list)
.item(&all_panels)
.separator()
.item(&toggle_properties)
.separator()
.item(&zoom_in)
.item(&zoom_out)
.item(&zoom_reset)
.separator()
.item(&command_palette)
.build()?)
build_manifest_menu(app, "View")
}
fn build_go_menu(app: &App) -> MenuResult {
let all_notes = MenuItemBuilder::new("All Notes")
.id(GO_ALL_NOTES)
.build(app)?;
let archived = MenuItemBuilder::new("Archived")
.id(GO_ARCHIVED)
.build(app)?;
let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?;
let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?;
let go_back = MenuItemBuilder::new("Go Back")
.id(VIEW_GO_BACK)
.accelerator("CmdOrCtrl+Left")
.build(app)?;
let go_forward = MenuItemBuilder::new("Go Forward")
.id(VIEW_GO_FORWARD)
.accelerator("CmdOrCtrl+Right")
.build(app)?;
Ok(SubmenuBuilder::new(app, "Go")
.item(&all_notes)
.item(&archived)
.item(&changes)
.item(&inbox)
.separator()
.item(&go_back)
.item(&go_forward)
.build()?)
build_manifest_menu(app, "Go")
}
fn build_note_menu(app: &App) -> MenuResult {
let toggle_organized = MenuItemBuilder::new("Toggle Organized")
.id(NOTE_TOGGLE_ORGANIZED)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let archive_note = MenuItemBuilder::new("Archive Note")
.id(NOTE_ARCHIVE)
.build(app)?;
let delete_note = MenuItemBuilder::new("Delete Note")
.id(NOTE_DELETE)
.accelerator("CmdOrCtrl+Backspace")
.build(app)?;
let restore_deleted_note = MenuItemBuilder::new("Restore Deleted Note")
.id(NOTE_RESTORE_DELETED)
.enabled(false)
.build(app)?;
let open_new_window = MenuItemBuilder::new("Open in New Window")
.id(NOTE_OPEN_IN_NEW_WINDOW)
.accelerator("CmdOrCtrl+Shift+O")
.build(app)?;
let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor")
.id(EDIT_TOGGLE_RAW_EDITOR)
.accelerator("CmdOrCtrl+\\")
.build(app)?;
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
.id(VIEW_TOGGLE_AI_CHAT)
.accelerator("CmdOrCtrl+Shift+L")
.build(app)?;
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
.id(VIEW_TOGGLE_BACKLINKS)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Note")
.item(&toggle_organized)
.item(&archive_note)
.item(&delete_note)
.item(&restore_deleted_note)
.separator()
.item(&open_new_window)
.separator()
.item(&toggle_raw_editor)
.item(&toggle_ai_chat)
.item(&toggle_backlinks)
.build()?)
build_manifest_menu(app, "Note")
}
fn build_vault_menu(app: &App) -> MenuResult {
let open_vault = MenuItemBuilder::new("Open Vault")
.id(VAULT_OPEN)
.build(app)?;
let remove_vault = MenuItemBuilder::new("Remove Vault from List")
.id(VAULT_REMOVE)
.build(app)?;
let restore_getting_started = MenuItemBuilder::new("Restore Getting Started")
.id(VAULT_RESTORE_GETTING_STARTED)
.build(app)?;
let add_remote = MenuItemBuilder::new("Add Remote…")
.id(VAULT_ADD_REMOTE)
.enabled(false)
.build(app)?;
let commit_push = MenuItemBuilder::new("Commit & Push")
.id(VAULT_COMMIT_PUSH)
.build(app)?;
let pull = MenuItemBuilder::new("Pull from Remote")
.id(VAULT_PULL)
.build(app)?;
let resolve_conflicts = MenuItemBuilder::new("Resolve Conflicts")
.id(VAULT_RESOLVE_CONFLICTS)
.enabled(false)
.build(app)?;
let view_changes = MenuItemBuilder::new("View Pending Changes")
.id(VAULT_VIEW_CHANGES)
.build(app)?;
let install_mcp = MenuItemBuilder::new("Set Up External AI Tools…")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reload = MenuItemBuilder::new("Reload Vault")
.id(VAULT_RELOAD)
.build(app)?;
let repair = MenuItemBuilder::new("Repair Vault")
.id(VAULT_REPAIR)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Vault")
.item(&open_vault)
.item(&remove_vault)
.item(&restore_getting_started)
.separator()
.item(&add_remote)
.item(&commit_push)
.item(&pull)
.item(&resolve_conflicts)
.item(&view_changes)
.separator()
.item(&reload)
.item(&repair)
.item(&install_mcp)
.build()?)
build_manifest_menu(app, "Vault")
}
fn build_window_menu(app: &App) -> MenuResult {
@@ -434,7 +355,7 @@ fn build_window_menu(app: &App) -> MenuResult {
.build()?)
}
pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
pub fn setup_menu(app: &App) -> Result<(), Box<dyn Error>> {
let app_menu = build_app_menu(app)?;
let file_menu = build_file_menu(app)?;
let edit_menu = build_edit_menu(app)?;
@@ -465,174 +386,174 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn emitted_menu_event_id(id: &str) -> Option<&'static str> {
manifest_menu_items().find_map(|item| {
if item.menu_item_id(manifest()) == Some(id) {
item.command_id(manifest())
} else {
None
}
})
}
pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), String> {
if !CUSTOM_IDS.contains(&id) {
if !custom_menu_ids().contains(id) {
return Err(format!("Unknown custom menu event: {id}"));
}
let emitted_id = match id {
FILE_QUICK_OPEN_ALIAS => FILE_QUICK_OPEN,
_ => id,
};
let emitted_id = emitted_menu_event_id(id)
.ok_or_else(|| format!("Missing emitted command for custom menu event: {id}"))?;
app_handle
.emit("menu-event", emitted_id)
.map_err(|err| format!("Failed to emit menu-event {emitted_id}: {err}"))
}
fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) {
fn menu_state_group_ids(group_name: &str) -> Vec<&'static str> {
manifest()
.menu_state_groups
.get(group_name)
.into_iter()
.flatten()
.filter_map(|reference| match reference {
MenuStateGroupReference::Command { command } => manifest()
.commands
.get(command)
.map(|command| command.id.as_str()),
MenuStateGroupReference::Id { id } => Some(id.as_str()),
})
.collect()
}
fn set_items_enabled<'a>(
app_handle: &AppHandle,
ids: impl IntoIterator<Item = &'a str>,
enabled: bool,
) {
let Some(menu) = app_handle.menu() else {
return;
};
for id in ids {
if let Some(MenuItemKind::MenuItem(mi)) = menu.get(*id) {
if let Some(MenuItemKind::MenuItem(mi)) = menu.get(id) {
let _ = mi.set_enabled(enabled);
}
}
}
fn set_menu_state_group_enabled(app_handle: &AppHandle, group_name: &str, enabled: bool) {
set_items_enabled(app_handle, menu_state_group_ids(group_name), enabled);
}
/// Enable or disable menu items that depend on having an active note tab.
pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
set_menu_state_group_enabled(app_handle, NOTE_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on the editor being the active surface.
pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, EDITOR_FIND_DEPENDENT_IDS, enabled);
set_menu_state_group_enabled(app_handle, EDITOR_FIND_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on the note list being the active surface.
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled);
set_menu_state_group_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on having uncommitted changes.
pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled);
set_menu_state_group_enabled(app_handle, GIT_COMMIT_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on having merge conflicts.
pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_CONFLICT_DEPENDENT_IDS, enabled);
set_menu_state_group_enabled(app_handle, GIT_CONFLICT_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on the active vault having no remote.
pub fn set_git_no_remote_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_NO_REMOTE_DEPENDENT_IDS, enabled);
set_menu_state_group_enabled(app_handle, GIT_NO_REMOTE_DEPENDENT_GROUP, enabled);
}
/// Enable or disable menu items that depend on a deleted note preview being active.
pub fn set_restore_deleted_item_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, RESTORE_DELETED_DEPENDENT_IDS, enabled);
set_menu_state_group_enabled(app_handle, RESTORE_DELETED_DEPENDENT_GROUP, enabled);
}
#[cfg(test)]
mod tests {
use super::*;
fn menu_item_by_id(id: &str) -> &'static ManifestMenuItem {
manifest_menu_items()
.find(|item| item.menu_item_id(manifest()) == Some(id))
.unwrap_or_else(|| panic!("missing menu item {id}"))
}
#[test]
fn custom_ids_include_all_constants() {
let expected = [
APP_SETTINGS,
APP_CHECK_FOR_UPDATES,
FILE_NEW_NOTE,
FILE_NEW_TYPE,
FILE_QUICK_OPEN,
FILE_SAVE,
EDIT_FIND_IN_NOTE,
EDIT_REPLACE_IN_NOTE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_NOTE_LIST_SEARCH,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
VIEW_EDITOR_LIST,
VIEW_ALL,
VIEW_TOGGLE_PROPERTIES,
VIEW_TOGGLE_AI_CHAT,
VIEW_TOGGLE_BACKLINKS,
VIEW_COMMAND_PALETTE,
VIEW_ZOOM_IN,
VIEW_ZOOM_OUT,
VIEW_ZOOM_RESET,
VIEW_GO_BACK,
VIEW_GO_FORWARD,
GO_ALL_NOTES,
GO_ARCHIVED,
GO_CHANGES,
NOTE_ARCHIVE,
NOTE_DELETE,
NOTE_OPEN_IN_NEW_WINDOW,
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_ADD_REMOTE,
VAULT_COMMIT_PUSH,
VAULT_PULL,
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_RELOAD,
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
fn custom_ids_are_manifest_menu_item_ids() {
let expected: HashSet<_> = manifest_menu_items()
.filter_map(|item| item.menu_item_id(manifest()))
.map(str::to_owned)
.collect();
assert_eq!(custom_menu_ids(), &expected);
assert!(custom_menu_ids().contains("file-quick-open-alias"));
}
#[test]
fn manifest_command_items_reference_known_commands() {
for item in manifest_menu_items() {
if let ManifestMenuItem::Command { command, .. } = item {
assert!(
manifest().commands.contains_key(command),
"menu item references missing command key {command}"
);
}
}
}
#[test]
fn note_dependent_ids_are_subset_of_custom_ids() {
for id in NOTE_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"note-dependent ID {id} not in CUSTOM_IDS"
);
fn overridden_menu_item_ids_emit_their_primary_command() {
assert_eq!(
emitted_menu_event_id("file-quick-open-alias"),
Some("file-quick-open")
);
assert_eq!(
emitted_menu_event_id("edit-toggle-note-list-search"),
Some("edit-toggle-note-list-search")
);
assert_eq!(emitted_menu_event_id("file-save"), Some("file-save"));
}
#[test]
fn state_group_ids_are_manifest_menu_items() {
for (group, references) in &manifest().menu_state_groups {
for reference in references {
let id = match reference {
MenuStateGroupReference::Command { command } => manifest()
.commands
.get(command)
.map(|command| command.id.as_str())
.unwrap_or_else(|| panic!("state group {group} references {command}")),
MenuStateGroupReference::Id { id } => id.as_str(),
};
assert!(
custom_menu_ids().contains(id),
"state group {group} references non-menu item {id}"
);
}
}
}
#[test]
fn note_list_search_dependent_ids_are_subset_of_custom_ids() {
for id in NOTE_LIST_SEARCH_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"note-list-search-dependent ID {id} not in CUSTOM_IDS"
);
}
}
fn view_toggle_properties_keeps_renderer_owned_accelerator() {
let item = menu_item_by_id("view-toggle-properties");
#[test]
fn editor_find_dependent_ids_are_subset_of_custom_ids() {
for id in EDITOR_FIND_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"editor-find-dependent ID {id} not in CUSTOM_IDS"
);
}
}
#[test]
fn git_dependent_ids_are_subset_of_custom_ids() {
for id in GIT_COMMIT_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"git-commit-dependent ID {id} not in CUSTOM_IDS"
);
}
for id in GIT_CONFLICT_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"git-conflict-dependent ID {id} not in CUSTOM_IDS"
);
}
for id in GIT_NO_REMOTE_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"git-no-remote-dependent ID {id} not in CUSTOM_IDS"
);
}
assert_eq!(item.accelerator(manifest()), None);
}
#[test]
fn no_duplicate_custom_ids() {
let mut seen = std::collections::HashSet::new();
for id in CUSTOM_IDS {
let mut seen = HashSet::new();
for id in manifest_menu_items().filter_map(|item| item.menu_item_id(manifest())) {
assert!(seen.insert(id), "duplicate custom ID: {id}");
}
}

View File

@@ -8,6 +8,7 @@ pub(crate) fn build_command(
request: &AgentStreamRequest,
) -> Result<std::process::Command, String> {
let mut command = crate::hidden_command(binary);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
command
.args(build_args())
.arg(build_prompt(request))

View File

@@ -11,6 +11,7 @@ pub(crate) fn build_command(
write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?;
let mut command = crate::hidden_command(binary);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
command
.args(build_args())
.arg(build_prompt(request))

View File

@@ -1,3 +1,4 @@
use crate::frontmatter::keys::{canonical_known_frontmatter_key, FrontmatterKey};
use crate::vault::parsing::contains_wikilink;
use serde::Deserialize;
use std::collections::HashMap;
@@ -182,88 +183,52 @@ fn sanitize_value(value: &serde_json::Value) -> serde_json::Value {
}
}
/// Parse frontmatter from raw YAML data extracted by gray_matter.
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
static KNOWN_KEYS: &[&str] = &[
"title",
"type",
"Is A",
"is_a",
"aliases",
"_archived",
"Archived",
"archived",
"_icon",
"icon",
"color",
"_order",
"order",
"_sidebar_label",
"sidebar_label",
"sidebar label",
"template",
"_sort",
"sort",
"view",
"_width",
"width",
"visible",
"notion_id",
"Status",
"status",
"_organized",
"_favorite",
"_favorite_index",
"_list_properties_display",
];
let filtered: serde_json::Map<String, serde_json::Value> = data
.iter()
.filter(|(k, _)| KNOWN_KEYS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), sanitize_value(v)))
.collect();
let value = serde_json::Value::Object(filtered);
serde_json::from_value(value).unwrap_or_default()
fn insert_known_frontmatter_value(
target: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: &serde_json::Value,
overwrite: bool,
) {
let Some(canonical_key) = canonical_known_frontmatter_key(FrontmatterKey::new(key)) else {
return;
};
if overwrite || !target.contains_key(canonical_key) {
target.insert(canonical_key.to_string(), sanitize_value(value));
}
}
/// Known non-relationship frontmatter keys to skip (case-insensitive comparison).
/// Only skip keys that can never contain wikilinks.
/// Note: owner and cadence are NOT skipped — they should appear in generic properties.
const SKIP_KEYS: &[&str] = &[
"title",
"is_a",
"type",
"aliases",
"_archived",
"archived",
"icon",
"color",
"order",
"sidebar_label",
"template",
"sort",
"view",
"_width",
"width",
"visible",
"status",
"_organized",
"_favorite",
"_favorite_index",
"_list_properties_display",
];
fn raw_frontmatter_keys(raw_content: &str) -> Vec<String> {
RawFrontmatter(raw_content)
.extract_block()
.map(|raw| {
raw.lines()
.filter_map(|line| YamlLine(line).key().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
#[derive(Clone, Copy)]
struct FrontmatterKey<'a>(&'a str);
impl FrontmatterKey<'_> {
fn normalize(self) -> String {
self.0.trim().to_ascii_lowercase().replace(' ', "_")
fn known_frontmatter_map(
data: &HashMap<String, serde_json::Value>,
raw_content: &str,
) -> serde_json::Map<String, serde_json::Value> {
let mut filtered = serde_json::Map::new();
for key in raw_frontmatter_keys(raw_content) {
if let Some(value) = data.get(&key) {
insert_known_frontmatter_value(&mut filtered, &key, value, true);
}
}
fn should_skip(self) -> bool {
let normalized = self.normalize();
normalized.starts_with('_') || SKIP_KEYS.contains(&normalized.as_str())
for (key, value) in data {
insert_known_frontmatter_value(&mut filtered, key, value, false);
}
filtered
}
/// Parse frontmatter from raw YAML data extracted by gray_matter.
fn parse_frontmatter(data: &HashMap<String, serde_json::Value>, raw_content: &str) -> Frontmatter {
let filtered = known_frontmatter_map(data, raw_content);
let value = serde_json::Value::Object(filtered);
serde_json::from_value(value).unwrap_or_default()
}
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -273,7 +238,7 @@ pub(crate) fn extract_relationships(
let mut relationships = HashMap::new();
for (key, value) in data {
if FrontmatterKey(key).should_skip() {
if FrontmatterKey::new(key).is_reserved() {
continue;
}
@@ -306,7 +271,7 @@ pub(crate) fn extract_properties(
let mut properties = HashMap::new();
for (key, value) in data {
if FrontmatterKey(key).should_skip() {
if FrontmatterKey::new(key).is_reserved() {
continue;
}
@@ -419,7 +384,7 @@ impl<'a> YamlLine<'a> {
return None;
}
let (key, _) = self.0.split_once(':')?;
Some(key.trim().trim_matches('"'))
Some(key.trim().trim_matches('"').trim_matches('\''))
}
fn list_item(self) -> Option<&'a str> {
@@ -519,7 +484,7 @@ pub(crate) fn extract_fm_and_rels(
}
};
(
parse_frontmatter(&json_map),
parse_frontmatter(&json_map, raw_content),
extract_relationships(&json_map),
extract_properties(&json_map),
)

View File

@@ -133,3 +133,13 @@ fn test_alias_parser_recovers_special_alias_items() {
assert_alias_parser_recovers(case);
}
}
#[test]
fn test_alias_collisions_keep_frontmatter_with_last_value_winning() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Note\nstatus: Active\nStatus: Evergreened\n---\n# Test\n";
let entry = parse_test_entry(&dir, "test.md", content);
assert_eq!(entry.is_a, Some("Note".to_string()));
assert_eq!(entry.status, Some("Evergreened".to_string()));
}

View File

@@ -23,6 +23,10 @@ fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
}
fn has_gitignore_file(vault_path: &Path) -> bool {
if vault_path.join(".gitignore").is_file() {
return true;
}
WalkDir::new(vault_path)
.follow_links(false)
.into_iter()
@@ -43,14 +47,17 @@ fn run_git_check_ignore(vault_path: &Path, relative_paths: &[String]) -> Option<
.spawn()
.ok()?;
{
let stdin = child.stdin.as_mut()?;
for path in relative_paths {
writeln!(stdin, "{path}").ok()?;
let mut stdin = child.stdin.take()?;
let paths = relative_paths.to_vec();
let writer = std::thread::spawn(move || -> std::io::Result<()> {
for path in paths {
writeln!(stdin, "{path}")?;
}
}
Ok(())
});
let output = child.wait_with_output().ok()?;
writer.join().ok()?.ok()?;
if output.status.success() || output.status.code() == Some(1) {
return Some(String::from_utf8_lossy(&output.stdout).to_string());
}
@@ -186,6 +193,8 @@ pub fn filter_gitignored_folders(
mod tests {
use super::*;
use std::fs;
use std::sync::mpsc;
use std::time::Duration;
use tempfile::TempDir;
fn write_file(root: &Path, relative: &str, content: &str) {
@@ -288,6 +297,34 @@ mod tests {
assert_eq!(filtered[0].path, "notes");
}
#[test]
fn filters_large_ignored_folder_sets_without_blocking_on_git_stdout() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "generated/\n");
let folders = (0..6_000)
.map(|index| FolderNode {
name: format!("package-{index}"),
path: format!("generated/package-{index}"),
children: vec![],
})
.collect::<Vec<_>>();
let vault_path = dir.path().to_path_buf();
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let filtered = filter_gitignored_folders(vault_path.as_path(), folders, true);
let _ = sender.send(filtered);
drop(dir);
});
let filtered = receiver
.recv_timeout(Duration::from_secs(5))
.expect("large gitignored folder filtering should not block on child stdout");
assert!(filtered.is_empty());
}
#[test]
fn has_no_effect_without_gitignore_file() {
let dir = TempDir::new().unwrap();

View File

@@ -85,6 +85,16 @@ fn test_parse_type_key_lowercase() {
assert_eq!(entry.is_a, Some("Project".to_string()));
}
#[test]
fn test_parse_type_key_case_insensitive() {
for (key, expected_type) in [("Type", "Project"), ("TYPE", "Person")] {
let dir = TempDir::new().unwrap();
let content = format!("---\n{key}: {expected_type}\n---\n# Test\n");
let entry = parse_test_entry(&dir, "note/test.md", &content);
assert_eq!(entry.is_a, Some(expected_type.to_string()));
}
}
#[test]
fn test_type_key_generates_type_relationship() {
let dir = TempDir::new().unwrap();

View File

@@ -5,6 +5,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::cmp::Ordering;
use std::fmt;
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use super::VaultEntry;
@@ -291,7 +292,11 @@ pub fn save_view(
/// Delete a view file at `vault_path/views/{filename}`.
pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> {
let path = vault_path.join("views").join(filename);
fs::remove_file(&path).map_err(|e| format!("Failed to delete view: {}", e))
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("Failed to delete view: {}", error)),
}
}
/// Evaluate a view definition against vault entries, returning indices of matching entries.
@@ -543,67 +548,65 @@ fn evaluate_scalar_op(
raw_value: &Option<serde_yaml::Value>,
) -> bool {
match op {
FilterOp::Equals => match (field_value, cond_value) {
(Some(f), Some(v)) => f.eq_ignore_ascii_case(v),
(None, None) => true,
_ => false,
},
FilterOp::NotEquals => match (field_value, cond_value) {
(Some(f), Some(v)) => !f.eq_ignore_ascii_case(v),
(None, None) => false,
_ => true,
},
FilterOp::Contains => match (field_value, cond_value) {
(Some(f), Some(v)) => f.to_lowercase().contains(&v.to_lowercase()),
_ => false,
},
FilterOp::NotContains => match (field_value, cond_value) {
(Some(f), Some(v)) => !f.to_lowercase().contains(&v.to_lowercase()),
(None, _) => true,
_ => true,
},
FilterOp::AnyOf => {
let values = raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
match field_value {
Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => false,
}
}
FilterOp::NoneOf => {
let values = raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default();
match field_value {
Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)),
None => true,
}
}
FilterOp::Equals => scalar_equals(field_value, cond_value),
FilterOp::NotEquals => !scalar_equals(field_value, cond_value),
FilterOp::Contains => scalar_contains(field_value, cond_value),
FilterOp::NotContains => !scalar_contains(field_value, cond_value),
FilterOp::AnyOf => scalar_matches_any(field_value, raw_value),
FilterOp::NoneOf => !scalar_matches_any(field_value, raw_value),
FilterOp::IsEmpty => field_value.map_or(true, str::is_empty),
FilterOp::IsNotEmpty => field_value.is_some_and(|s| !s.is_empty()),
FilterOp::Before => match (field_value, cond_value) {
(Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
) {
(Some(field_ts), Some(target_ts)) => field_ts < target_ts,
_ => false,
},
_ => false,
},
FilterOp::After => match (field_value, cond_value) {
(Some(f), Some(v)) => match (
parse_date_filter_timestamp(f, Utc::now()),
parse_date_filter_timestamp(v, Utc::now()),
) {
(Some(field_ts), Some(target_ts)) => field_ts > target_ts,
_ => false,
},
_ => false,
},
FilterOp::Before => {
scalar_date_compare(field_value, cond_value, |field, target| field < target)
}
FilterOp::After => {
scalar_date_compare(field_value, cond_value, |field, target| field > target)
}
}
}
fn scalar_equals(field_value: Option<&str>, cond_value: Option<&str>) -> bool {
match (field_value, cond_value) {
(Some(field), Some(value)) => field.eq_ignore_ascii_case(value),
(None, None) => true,
_ => false,
}
}
fn scalar_contains(field_value: Option<&str>, cond_value: Option<&str>) -> bool {
match (field_value, cond_value) {
(Some(field), Some(value)) => field.to_lowercase().contains(&value.to_lowercase()),
_ => false,
}
}
fn scalar_matches_any(field_value: Option<&str>, raw_value: &Option<serde_yaml::Value>) -> bool {
let Some(field) = field_value else {
return false;
};
raw_value
.as_ref()
.and_then(yaml_value_to_string_vec)
.unwrap_or_default()
.iter()
.any(|value| field.eq_ignore_ascii_case(value))
}
fn scalar_date_compare(
field_value: Option<&str>,
cond_value: Option<&str>,
predicate: impl FnOnce(i64, i64) -> bool,
) -> bool {
let (Some(field), Some(value)) = (field_value, cond_value) else {
return false;
};
let reference = Utc::now();
match (
parse_date_filter_timestamp(field, reference),
parse_date_filter_timestamp(value, reference),
) {
(Some(field_ts), Some(target_ts)) => predicate(field_ts, target_ts),
_ => false,
}
}
@@ -1067,6 +1070,25 @@ filters:
assert_eq!(views.len(), 0);
}
#[test]
fn test_delete_view_treats_missing_file_as_deleted() {
let dir = tempfile::TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("views")).unwrap();
delete_view(dir.path(), "missing.yml").unwrap();
assert!(scan_views(dir.path()).is_empty());
}
#[test]
fn test_delete_view_treats_missing_views_directory_as_deleted() {
let dir = tempfile::TempDir::new().unwrap();
delete_view(dir.path(), "missing.yml").unwrap();
assert!(scan_views(dir.path()).is_empty());
}
#[test]
fn test_save_and_read_view_with_emoji_icon() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -21,6 +21,10 @@
"resizable": true,
"fullscreen": false,
"titleBarStyle": "Overlay",
"trafficLightPosition": {
"x": 18,
"y": 24
},
"hiddenTitle": true,
"backgroundColor": "#F7F6F3",
"dragDropEnabled": true

View File

@@ -26,15 +26,23 @@
flex: 1;
}
.app:has(.app__note-list) .app__sidebar > * {
border-right-color: transparent;
}
.app__note-list {
flex: 0 0 auto;
min-width: 220px;
display: flex;
flex-direction: column;
position: relative;
z-index: 20;
background: var(--surface-sidebar);
}
.app__note-list > * {
flex: 1;
border-left: 1px solid var(--sidebar-border);
}
.app__editor {

View File

@@ -257,13 +257,20 @@ function getHeaderForNoteList(noteListContainer: HTMLElement) {
return within(noteListContainer.parentElement as HTMLElement).getByRole('heading', { level: 3 })
}
async function enterNeighborhood(noteListContainer: HTMLElement, title: string) {
async function clickNoteListItem(noteListContainer: HTMLElement, title: string, options?: MouseEventInit) {
await waitFor(() => {
expect(within(noteListContainer).getByText(title)).toBeInTheDocument()
})
await act(async () => {
fireEvent.click(within(noteListContainer).getByText(title), { metaKey: true })
fireEvent.click(within(noteListContainer).getByText(title), options)
await Promise.resolve()
})
}
async function enterNeighborhood(noteListContainer: HTMLElement, title: string) {
await clickNoteListItem(noteListContainer, title, { metaKey: true })
}
async function pressEscape() {
await act(async () => {
fireEvent.keyDown(window, { key: 'Escape' })
@@ -372,9 +379,9 @@ vi.mock('@blocknote/core/extensions', () => ({
vi.mock('@blocknote/react', () => ({
createReactBlockSpec: () => () => ({}),
createReactInlineContentSpec: () => ({ render: () => null }),
BlockNoteViewRaw: ({ children }: { children?: ReactNode }) => (
<div data-testid="blocknote-view">
<div contentEditable suppressContentEditableWarning data-testid="mock-editor">
BlockNoteViewRaw: ({ children, editable }: { children?: ReactNode; editable?: boolean }) => (
<div data-testid="blocknote-view" data-editable={editable !== false ? 'true' : 'false'}>
<div contentEditable={editable !== false} suppressContentEditableWarning data-testid="mock-editor">
mock editor
</div>
{children}
@@ -438,6 +445,7 @@ import { streamAiAgent } from './utils/streamAiAgent'
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
const SLOW_APP_READY_TIMEOUT_MS = 10_000
function createMockUpdaterResult(
checkForUpdates: () => Promise<{ kind: 'up-to-date' } | { kind: 'available'; version: string; displayVersion: string } | { kind: 'error'; message: string }> = async () => ({ kind: 'up-to-date' }),
@@ -578,6 +586,7 @@ describe('App', () => {
await waitFor(() => expect(getNoteContent).toHaveBeenCalled())
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).not.toHaveBeenCalled()
})
@@ -661,7 +670,7 @@ describe('App', () => {
await waitFor(() => {
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
})
}, { timeout: SLOW_APP_READY_TIMEOUT_MS })
await waitFor(() => {
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
@@ -834,7 +843,7 @@ describe('App', () => {
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
})
it('keeps startup on a neutral loading state while the last vault is still resolving', async () => {
it('uses the app shell loading state while the last vault is still resolving', async () => {
localStorage.setItem('tolaria_welcome_dismissed', '1')
let resolveVaultList: ((value: typeof mockVaultList) => void) | null = null
@@ -853,7 +862,12 @@ describe('App', () => {
await Promise.resolve()
})
expect(screen.getByTestId('vault-loading-skeleton')).toBeInTheDocument()
expect(screen.queryByTestId('vault-loading-skeleton')).not.toBeInTheDocument()
expect(screen.getByTestId('sidebar-loading-favorites')).toBeInTheDocument()
expect(screen.getByTestId('note-list-loading-skeleton')).toBeInTheDocument()
expect(screen.getByTestId('breadcrumb-title-skeleton')).toBeInTheDocument()
expect(screen.getByTestId('editor-content-skeleton')).toBeInTheDocument()
expect(screen.getByTestId('status-vault-reloading')).toHaveAccessibleName('Reloading vault from disk')
expect(screen.queryByText('Vault not found')).not.toBeInTheDocument()
await act(async () => {
@@ -925,7 +939,7 @@ describe('App', () => {
await waitFor(() => {
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
}, { timeout: SLOW_APP_READY_TIMEOUT_MS })
fireEvent.click(screen.getByTestId('welcome-open-folder'))
@@ -1059,8 +1073,14 @@ describe('App', () => {
render(<App />)
const sidebar = await screen.findByText('FAVORITES')
fireEvent.click(within(sidebar.closest('div')?.parentElement as HTMLElement).getByText('Alpha'))
let favoritesSection: HTMLElement | undefined
await waitFor(() => {
const sidebar = screen.getByText('FAVORITES')
const currentFavoritesSection = sidebar.closest('div')?.parentElement as HTMLElement
expect(within(currentFavoritesSection).getByText('Alpha')).toBeInTheDocument()
favoritesSection = currentFavoritesSection
})
fireEvent.click(within(favoritesSection!).getByText('Alpha'))
const noteListContainer = await screen.findByTestId('note-list-container')
await waitFor(() => {
@@ -1116,10 +1136,7 @@ describe('App', () => {
expect(getHeaderForNoteList(noteListContainer)).toHaveTextContent('Inbox')
})
await act(async () => {
fireEvent.click(within(noteListContainer).getByText('Alpha'))
await Promise.resolve()
})
await clickNoteListItem(noteListContainer, 'Alpha')
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Set note as organized' })).toBeInTheDocument()
@@ -1133,7 +1150,7 @@ describe('App', () => {
await waitFor(() => {
expect(window.__laputaTest?.activeTabPath).toBe('/vault/beta.md')
})
})
}, 10_000)
it('keeps the manually selected note after organizing finishes later', async () => {
configureNeighborhoodVault()
@@ -1160,10 +1177,7 @@ describe('App', () => {
expect(getHeaderForNoteList(noteListContainer)).toHaveTextContent('Inbox')
})
await act(async () => {
fireEvent.click(within(noteListContainer).getByText('Alpha'))
await Promise.resolve()
})
await clickNoteListItem(noteListContainer, 'Alpha')
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Set note as organized' })).toBeInTheDocument()
@@ -1190,7 +1204,7 @@ describe('App', () => {
})
expect(window.__laputaTest?.activeTabPath).toBe('/vault/gamma.md')
})
}, 10_000)
it('renders status bar', async () => {
render(<App />)

View File

@@ -16,7 +16,6 @@ import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { AppLoadingSkeleton } from './components/AppLoadingSkeleton'
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
@@ -109,6 +108,7 @@ import {
buildVaultAiGuidanceRefreshKey,
} from './lib/vaultAiGuidance'
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
import { isActiveVaultUnavailableError } from './utils/vaultErrors'
import { hasNoteIconValue } from './utils/noteIcon'
import { filenameStemToTitle } from './utils/noteTitle'
import {
@@ -125,6 +125,7 @@ import {
isExplicitOrganizationEnabled,
sanitizeSelectionForOrganization,
} from './utils/organizationWorkflow'
import { requestPlainTextPaste } from './utils/plainTextPaste'
import './App.css'
// Type declarations for mock content storage and test overrides
@@ -375,6 +376,7 @@ function App() {
}, [resolvedPath, setToastMessage])
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null
const {
markInternalWrite: markRecentVaultWrite,
filterExternalPaths: filterExternalVaultPaths,
@@ -578,6 +580,9 @@ function App() {
markRecentVaultWrite(path)
vault.loadModifiedFiles()
}, [markRecentVaultWrite, vault])
const handleMissingActiveVault = useCallback(() => {
if (!noteWindowParams && resolvedPath) vault.markVaultUnavailable(resolvedPath)
}, [noteWindowParams, resolvedPath, vault])
const notes = useNoteActions({
addEntry: vault.addEntry,
@@ -596,6 +601,7 @@ function App() {
unsavedPaths: vault.unsavedPaths,
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
onNewNotePersisted: handleCreatedVaultEntryPersisted,
onMissingActiveVault: handleMissingActiveVault,
onTypeStateChanged: async () => { await vault.reloadVault() },
replaceEntry: vault.replaceEntry,
onFrontmatterPersisted: vault.loadModifiedFiles,
@@ -1169,7 +1175,15 @@ function App() {
const handleDeleteView = useCallback(async (filename: string) => {
const target = isTauri() ? invoke : mockInvoke
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
try {
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
} catch (err) {
if (isActiveVaultUnavailableError(err)) {
vault.markVaultUnavailable(resolvedPath)
return
}
throw err
}
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
@@ -1230,6 +1244,10 @@ function App() {
updateMainWindowConstraints(mode === 'all', mode !== 'editor-only')
}, [setViewMode, updateMainWindowConstraints])
const handleCollapseSidebar = useCallback(() => {
handleSetViewMode('editor-list')
}, [handleSetViewMode])
const handleToggleInspector = useCallback(() => {
const nextInspectorCollapsed = !layout.inspectorCollapsed
layout.setInspectorCollapsed(nextInspectorCollapsed)
@@ -1403,6 +1421,11 @@ function App() {
const replaceInNoteCommand = useCallback(() => {
findInNoteRef.current?.({ replace: true })
}, [])
const pastePlainTextCommand = useCallback(() => {
void requestPlainTextPaste().catch((error) => {
console.warn('[paste] Failed to paste plain text:', error)
})
}, [])
const removeActiveVaultCommand = useCallback(() => {
vaultSwitcher.removeVault(vaultSwitcher.vaultPath)
}, [vaultSwitcher])
@@ -1492,6 +1515,7 @@ function App() {
onSearch: dialogs.openSearch,
onFindInNote: findInNoteCommand,
onReplaceInNote: activeDeletedFile ? undefined : replaceInNoteCommand,
onPastePlainText: pastePlainTextCommand,
onCreateNote: notes.handleCreateNoteImmediate,
onCreateNoteOfType: notes.handleCreateNoteImmediate,
onSave: appSave.handleSave,
@@ -1607,14 +1631,11 @@ function App() {
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
}, [onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
// Show loading skeleton while checking vault (skip for note windows)
if (!noteWindowParams && onboarding.state.status === 'loading') {
return <AppLoadingSkeleton />
}
const isStartupLoading = !noteWindowParams && onboarding.state.status === 'loading'
// Show telemetry consent dialog on first launch (skip for note windows).
// After the user answers, the next render can continue into onboarding.
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
if (!noteWindowParams && !isStartupLoading && settingsLoaded && settings.telemetry_consent === null) {
return (
<TelemetryConsentDialog
onAccept={() => {
@@ -1629,8 +1650,17 @@ function App() {
}
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
const welcomeOnboarding = shouldResumeFreshStartOnboarding
if (!noteWindowParams && (runtimeMissingVaultPath || onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
const welcomeOnboarding = runtimeMissingVaultPath
? {
...onboarding,
state: {
status: 'vault-missing' as const,
vaultPath: runtimeMissingVaultPath,
defaultPath: vaultSwitcher.defaultPath || runtimeMissingVaultPath,
},
}
: shouldResumeFreshStartOnboarding
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
: onboarding
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
@@ -1653,7 +1683,7 @@ function App() {
)
}
const isVaultContentLoading = !noteWindowParams && onboarding.state.status === 'ready' && vault.isLoading
const isVaultContentLoading = !noteWindowParams && (isStartupLoading || (onboarding.state.status === 'ready' && vault.isLoading))
return (
<div className="app-shell">
@@ -1661,7 +1691,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} onCollapse={handleCollapseSidebar} onGoBack={handleGoBack} onGoForward={handleGoForward} canGoBack={canGoBack} canGoForward={canGoForward} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -1683,7 +1713,7 @@ function App() {
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
isVaultLoading={isVaultContentLoading}
entries={vault.entries}
entries={noteWindowParams && activeTab ? [activeTab.entry] : vault.entries}
onNavigateWikilink={notes.handleNavigateWikilink}
onLoadDiff={vault.loadDiff}
onLoadDiffAtCommit={vault.loadDiffAtCommit}
@@ -1748,7 +1778,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} 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} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || vault.isLoading} 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} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} 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} 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} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />

View File

@@ -0,0 +1,55 @@
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { AiPanelMessageHistory } from './AiPanelChrome'
const aiMessageRender = vi.hoisted(() => vi.fn())
vi.mock('./AiMessage', () => ({
AiMessage: (props: { id?: string; response: string }) => {
aiMessageRender(props.id)
return <div data-testid="ai-message">{props.response}</div>
},
}))
const noop = () => {}
function HistoryRerenderHarness() {
const [draft, setDraft] = useState('')
const [messages] = useState([{
userMessage: 'Explain the note',
actions: [],
response: 'Here is a long answer with [[Test Note]].',
id: 'msg-stable',
}])
return (
<>
<button type="button" onClick={() => setDraft('typing')}>type</button>
<span data-testid="draft">{draft}</span>
<AiPanelMessageHistory
agentLabel="Claude Code"
agentReadiness="ready"
messages={messages}
isActive={false}
onOpenNote={noop}
onNavigateWikilink={noop}
hasContext
/>
</>
)
}
describe('AiPanelChrome performance', () => {
it('keeps stable message history from re-rendering while composer state changes', () => {
render(<HistoryRerenderHarness />)
expect(screen.getByTestId('ai-message')).toHaveTextContent('Here is a long answer')
expect(aiMessageRender).toHaveBeenCalledTimes(1)
aiMessageRender.mockClear()
fireEvent.click(screen.getByRole('button', { name: 'type' }))
expect(screen.getByTestId('draft')).toHaveTextContent('typing')
expect(aiMessageRender).not.toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { memo, useEffect, useRef } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
@@ -164,7 +164,7 @@ function AiPanelEmptyState({
)
}
export function AiPanelHeader({
export const AiPanelHeader = memo(function AiPanelHeader({
agentLabel,
agentReadiness,
locale = 'en',
@@ -221,7 +221,7 @@ export function AiPanelHeader({
/>
</div>
)
}
})
function AiPermissionModeToggle({
value,
@@ -277,7 +277,11 @@ function AiPermissionModeToggle({
)
}
export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: AiPanelContextBarProps) {
export const AiPanelContextBar = memo(function AiPanelContextBar({
activeEntry,
linkedCount,
locale = 'en',
}: AiPanelContextBarProps) {
const t = createTranslator(locale)
return (
@@ -293,9 +297,9 @@ export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: A
)}
</div>
)
}
})
export function AiPanelMessageHistory({
export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
agentLabel,
agentReadiness,
locale = 'en',
@@ -332,7 +336,7 @@ export function AiPanelMessageHistory({
<div ref={endRef} />
</div>
)
}
})
export function AiPanelComposer({
entries,

View File

@@ -1,295 +0,0 @@
import type { CSSProperties } from 'react'
import type { LucideIcon } from 'lucide-react'
import {
AlignJustify,
Archive,
CheckCircle2,
ChevronDown,
Code2,
FileText,
GitBranch,
Inbox,
Plus,
Search,
Settings,
SlidersHorizontal,
Sparkles,
Star,
Trash2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const SIDEBAR_GROUPS = [
{ rows: ['62%', '48%', '58%', '52%'], count: '24px' },
{ rows: ['72%'], count: '30px' },
{ rows: ['34%', '42%', '38%', '56%', '50%', '44%', '39%', '46%'], count: '26px' },
{ rows: ['38%', '52%', '32%'] },
]
const NOTE_ROWS = [
{ selected: false, title: '68%', snippet: '84%', chips: ['56px'] },
{ selected: true, title: '62%', snippet: '78%', chips: ['64px', '54px', '72px'] },
{ selected: false, title: '48%', snippet: '44%', chips: [] },
{ selected: false, title: '82%', snippet: '74%', chips: ['68px'] },
{ selected: false, title: '70%', snippet: '88%', chips: ['76px', '92px'] },
{ selected: false, title: '58%', snippet: '66%', chips: ['64px'] },
{ selected: false, title: '76%', snippet: '72%', chips: ['72px'] },
]
const EDITOR_ACTIONS = [Star, CheckCircle2, GitBranch, Code2, AlignJustify, Sparkles, Archive, Trash2, SlidersHorizontal]
const STATUS_LEFT = ['70px', '104px', '88px', '116px', '82px']
const STATUS_RIGHT = ['44px', '78px', '18px', '18px']
type AppLoadingSkeletonProps = {
noteListWidth?: number
showNoteList?: boolean
showSidebar?: boolean
sidebarWidth?: number
}
function SkeletonBar({ className, style }: { className?: string; style?: CSSProperties }) {
return (
<div
className={cn('animate-pulse rounded bg-muted', className)}
style={style}
/>
)
}
function SkeletonIcon({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) {
return (
<Icon
size={16}
strokeWidth={1.9}
className={cn(active ? 'text-primary' : 'text-muted-foreground', 'shrink-0 opacity-70')}
/>
)
}
function SidebarRow({ icon, width, active = false, countWidth }: {
icon: LucideIcon
width: string
active?: boolean
countWidth?: string
}) {
return (
<div
className={cn('flex items-center gap-2 rounded px-4 py-1.5', active && 'bg-primary/10')}
style={active ? { boxShadow: 'inset 3px 0 0 var(--primary)' } : undefined}
>
<SkeletonIcon icon={icon} active={active} />
<SkeletonBar className="h-3" style={{ width }} />
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
</div>
)
}
function SidebarSkeletonGlyph() {
return <SkeletonBar className="h-4 w-4 shrink-0 rounded-[4px]" />
}
function SidebarStubRow({ width, countWidth }: {
width: string
countWidth?: string
}) {
return (
<div className="flex items-center gap-2 rounded px-4 py-1.5">
<SidebarSkeletonGlyph />
<SkeletonBar className="h-3" style={{ width }} />
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
</div>
)
}
function SidebarGroupSkeleton({ rows, count }: { rows: string[]; count?: string }) {
return (
<div className="border-b border-border px-1.5 pb-2">
<div className="flex items-center gap-1 px-4 py-2">
<ChevronDown size={12} className="text-muted-foreground opacity-60" />
<SkeletonBar className="h-2.5" style={{ width: '54px' }} />
{count && <SkeletonBar className="ml-auto h-4 rounded-full" style={{ width: count }} />}
</div>
<div className="flex flex-col gap-1">
{rows.map((width, index) => (
<SidebarStubRow
key={`${width}-${index}`}
width={width}
countWidth={index > 3 ? '30px' : undefined}
/>
))}
</div>
</div>
)
}
function SidebarSkeleton() {
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<div className="h-[52px] shrink-0 border-b border-border" />
<nav className="flex-1 overflow-hidden py-1">
<div className="border-b border-border px-1.5 pb-1">
<SidebarRow icon={Inbox} width="48%" active countWidth="30px" />
<SidebarRow icon={FileText} width="54%" countWidth="38px" />
<SidebarRow icon={Archive} width="42%" countWidth="32px" />
</div>
{SIDEBAR_GROUPS.map((group, index) => (
<SidebarGroupSkeleton key={index} rows={group.rows} count={group.count} />
))}
</nav>
</aside>
)
}
function NoteRowSkeleton({ selected, title, snippet, chips }: {
selected: boolean
title: string
snippet: string
chips: string[]
}) {
return (
<div
className={cn('relative border-b border-border px-5 py-4', selected && 'bg-primary/5')}
style={selected ? { boxShadow: 'inset 3px 0 0 var(--accent-green)' } : undefined}
>
<SkeletonBar className="absolute right-4 top-4 h-3.5 w-3.5 rounded-sm" />
<SkeletonBar className="mb-3 h-3.5" style={{ width: title }} />
<div className="space-y-1.5">
<SkeletonBar className="h-2.5" style={{ width: snippet }} />
<SkeletonBar className="h-2.5" style={{ width: '58%' }} />
</div>
{chips.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{chips.map((width) => (
<SkeletonBar key={width} className="h-5 rounded" style={{ width }} />
))}
</div>
)}
<div className="mt-3 grid grid-cols-[1fr_auto] gap-3">
<SkeletonBar className="h-2.5" style={{ width: '38px' }} />
<SkeletonBar className="h-2.5" style={{ width: '72px' }} />
</div>
</div>
)
}
function NoteListSkeleton() {
return (
<section className="flex h-full flex-col overflow-hidden border-r border-border bg-card text-foreground">
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4">
<SkeletonBar className="h-4" style={{ width: '78px' }} />
<div className="flex items-center gap-3 text-muted-foreground">
<SkeletonBar className="h-2.5" style={{ width: '58px' }} />
<Search size={16} />
<SlidersHorizontal size={16} />
<Plus size={16} />
</div>
</header>
<div className="flex-1 overflow-hidden">
{NOTE_ROWS.map((row, index) => (
<NoteRowSkeleton key={index} {...row} />
))}
</div>
</section>
)
}
function EditorSkeleton() {
return (
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-5">
<div className="flex items-center gap-2">
<SkeletonBar className="h-3" style={{ width: '44px' }} />
<SkeletonBar className="h-3" style={{ width: '150px' }} />
</div>
<div className="flex items-center gap-5 text-muted-foreground">
{EDITOR_ACTIONS.map((Icon, index) => (
<Icon key={index} size={16} strokeWidth={1.8} className="opacity-65" />
))}
</div>
</header>
<article className="mx-auto flex w-full max-w-[760px] flex-1 flex-col px-10 py-16">
<SkeletonBar className="mb-7 h-9" style={{ width: '58%' }} />
<SkeletonBar className="mb-6 h-px w-full rounded-none" />
<div className="space-y-4">
<SkeletonBar className="h-4" style={{ width: '52%' }} />
<SkeletonBar className="h-4" style={{ width: '92%' }} />
<SkeletonBar className="h-4" style={{ width: '82%' }} />
<SkeletonBar className="h-4" style={{ width: '88%' }} />
<SkeletonBar className="h-4" style={{ width: '74%' }} />
</div>
<SkeletonBar className="my-10 h-px w-full rounded-none" />
<div className="space-y-4">
<SkeletonBar className="h-4" style={{ width: '22%' }} />
{[0, 1, 2].map((index) => (
<div key={index} className="flex items-center gap-4 pl-4">
<SkeletonBar className="h-2.5 w-2.5 rounded-full bg-primary/70" />
<SkeletonBar className="h-4" style={{ width: index === 1 ? '54%' : '44%' }} />
</div>
))}
</div>
</article>
</main>
)
}
function StatusSkeleton() {
return (
<footer
className="flex shrink-0 items-center justify-between border-t border-border bg-sidebar px-2 text-muted-foreground"
style={{ height: 30 }}
>
<div className="flex items-center gap-4">
{STATUS_LEFT.map((width, index) => (
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
))}
</div>
<div className="flex items-center gap-4">
{STATUS_RIGHT.map((width, index) => (
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
))}
<Settings size={14} className="opacity-60" />
</div>
</footer>
)
}
export function AppLoadingSkeleton({
noteListWidth = 350,
showNoteList = true,
showSidebar = true,
sidebarWidth = 250,
}: AppLoadingSkeletonProps) {
return (
<div
className="app-shell"
data-testid="vault-loading-skeleton"
role="status"
aria-busy="true"
aria-live="polite"
>
<span className="sr-only">Loading vault</span>
<div className="app" aria-hidden="true">
{showSidebar && (
<>
<div className="app__sidebar" style={{ width: sidebarWidth }}>
<SidebarSkeleton />
</div>
<div className="w-px shrink-0 bg-border" />
</>
)}
{showNoteList && (
<>
<div className="app__note-list" style={{ width: noteListWidth }}>
<NoteListSkeleton />
</div>
<div className="w-px shrink-0 bg-border" />
</>
)}
<div className="app__editor">
<EditorSkeleton />
</div>
</div>
<StatusSkeleton />
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent, act } from '@testing-library/react'
import { render, screen, fireEvent, act, within } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { BreadcrumbBar } from './BreadcrumbBar'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
@@ -179,16 +179,18 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
expect(screen.getByText('test')).toBeInTheDocument()
})
it('renders emoji note icons in the breadcrumb title', () => {
it('does not render emoji note icons in the breadcrumb filename', () => {
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
expect(screen.getByTestId('breadcrumb-note-icon')).toHaveTextContent('🚀')
expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test')
expect(screen.queryByText('🚀')).not.toBeInTheDocument()
})
it('renders Phosphor note icons in the breadcrumb title', () => {
it('does not render Phosphor note icons in the breadcrumb filename', () => {
const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' }
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} />)
expect(screen.getByTestId('breadcrumb-note-icon').tagName.toLowerCase()).toBe('svg')
expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test')
expect(screen.queryByTestId('breadcrumb-note-icon')).not.toBeInTheDocument()
})
it('falls back to "Note" when isA is null', () => {
@@ -284,6 +286,33 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => {
const actions = container.querySelector('.breadcrumb-bar__actions')
expect(actions).toBeInTheDocument()
expect(actions).toHaveClass('ml-auto')
expect(actions).toHaveStyle({ gap: '8px' })
})
it('keeps grouped action buttons evenly spaced', () => {
render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
noteWidth="normal"
onToggleNoteWidth={vi.fn()}
onRevealFile={vi.fn()}
onCopyFilePath={vi.fn()}
/>,
)
const fileActionsGroup = screen.getByTestId('breadcrumb-reveal-file').closest('.breadcrumb-bar__overflowable-action')
expect(fileActionsGroup).toHaveClass('gap-2')
const widthActionGroup = screen.getByRole('button', { name: 'Switch to wide note width' }).closest('.breadcrumb-bar__overflowable-action')
expect(widthActionGroup).toHaveClass('gap-2')
})
it('lets the title use the free space before the fixed drag gap', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
expect(container.querySelector('.breadcrumb-bar__title')).toHaveClass('flex-1')
expect(container.querySelector('.breadcrumb-bar__drag-spacer')).toHaveClass('w-6', 'shrink-0')
expect(container.querySelector('.breadcrumb-bar__drag-spacer')).not.toHaveClass('flex-1')
})
it('does not render the unused backlinks or more-actions placeholders', () => {
@@ -291,6 +320,35 @@ describe('BreadcrumbBar — action buttons always right-aligned', () => {
expect(screen.queryByRole('button', { name: 'Backlinks are coming soon' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'More note actions are coming soon' })).not.toBeInTheDocument()
})
it('exposes lower-priority actions through the overflow menu', async () => {
render(
<BreadcrumbBar
entry={baseEntry}
{...defaultProps}
showDiffToggle
noteWidth="normal"
onToggleNoteWidth={vi.fn()}
onRevealFile={vi.fn()}
onCopyFilePath={vi.fn()}
onArchive={vi.fn()}
onDelete={vi.fn()}
/>,
)
fireEvent.pointerDown(screen.getByRole('button', { name: 'More note actions' }), {
button: 0,
ctrlKey: false,
})
const menu = await screen.findByRole('menu')
expect(within(menu).getByRole('menuitem', { name: 'Show the current diff' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Switch to wide note width' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Reveal in Finder' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Copy file path' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Archive this note' })).toBeInTheDocument()
expect(within(menu).getByRole('menuitem', { name: 'Delete this note' })).toBeInTheDocument()
})
})
describe('BreadcrumbBar — raw editor toggle', () => {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import type { NoteWidthMode, VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { translate, type AppLocale } from '../lib/i18n'
@@ -7,11 +7,17 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ActionTooltip, type ActionTooltipCopy } from '@/components/ui/action-tooltip'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
GitBranch,
Code,
Sparkle,
SlidersHorizontal,
SidebarSimple,
Trash,
Archive,
ArrowUUpLeft,
@@ -22,8 +28,8 @@ import {
ArrowsClockwise,
ArrowsInLineHorizontal,
ArrowsOutLineHorizontal,
DotsThree,
} from '@phosphor-icons/react'
import { NoteTitleIcon } from './NoteTitleIcon'
import { slugify } from '../hooks/useNoteCreation'
import { useDragRegion } from '../hooks/useDragRegion'
@@ -60,6 +66,7 @@ interface BreadcrumbBarProps {
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
const BREADCRUMB_ICON_CLASS = 'size-[16px]'
const TITLE_ACTION_GAP_PX = 24
function focusFilenameInput(
isEditing: boolean,
@@ -428,11 +435,164 @@ function InspectorAction({
className="hover:text-foreground"
tooltipAlign="end"
>
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
<SidebarSimple size={16} weight="regular" className={BREADCRUMB_ICON_CLASS} style={{ transform: 'scaleX(-1)' }} />
</IconActionButton>
)
}
function OverflowToolbarAction({ children }: { children: ReactNode }) {
return <span className="breadcrumb-bar__overflowable-action flex items-center gap-2">{children}</span>
}
function diffMenuLabelKey({
showDiffToggle,
diffMode,
diffLoading,
}: Pick<BreadcrumbBarProps, 'showDiffToggle' | 'diffMode' | 'diffLoading'>): Parameters<typeof translate>[1] {
if (diffLoading) return 'editor.toolbar.loadingDiff'
if (diffMode) return 'editor.toolbar.rawReturn'
if (showDiffToggle) return 'editor.toolbar.showDiff'
return 'editor.toolbar.noDiff'
}
function availableDiffAction(showDiffToggle: boolean, onToggleDiff: () => void): (() => void) | undefined {
return showDiffToggle ? onToggleDiff : undefined
}
function noteWidthLabelKey(noteWidth: NoteWidthMode = 'normal'): Parameters<typeof translate>[1] {
return noteWidth === 'wide' ? 'editor.toolbar.noteWidthNormal' : 'editor.toolbar.noteWidthWide'
}
function NoteWidthMenuIcon({ noteWidth = 'normal' }: { noteWidth?: NoteWidthMode }) {
return noteWidth === 'wide' ? <ArrowsInLineHorizontal size={16} /> : <ArrowsOutLineHorizontal size={16} />
}
function archiveLabelKey(archived: boolean): Parameters<typeof translate>[1] {
return archived ? 'editor.toolbar.restoreArchived' : 'editor.toolbar.archive'
}
function archiveAction(
archived: boolean,
onArchive?: () => void,
onUnarchive?: () => void,
): (() => void) | undefined {
return archived ? onUnarchive : onArchive
}
function pathAction(action: ((path: string) => void) | undefined, path: string): (() => void) | undefined {
return action ? () => action(path) : undefined
}
function ArchiveMenuIcon({ archived }: { archived: boolean }) {
return archived ? <ArrowUUpLeft size={16} /> : <Archive size={16} />
}
function measureExpandedActionsWidth(
actions: HTMLDivElement,
collapsed: boolean,
cachedExpandedActionsWidth: number,
) {
return collapsed ? cachedExpandedActionsWidth || actions.scrollWidth : actions.scrollWidth
}
function readElementWidth(element: HTMLElement): number {
return element.getBoundingClientRect().width || element.scrollWidth || element.clientWidth
}
function prepareTitleMeasurementClone(clone: HTMLElement) {
clone.setAttribute('aria-hidden', 'true')
clone.style.position = 'absolute'
clone.style.visibility = 'hidden'
clone.style.pointerEvents = 'none'
clone.style.width = 'max-content'
clone.style.minWidth = 'max-content'
clone.style.maxWidth = 'none'
clone.style.overflow = 'visible'
clone.style.whiteSpace = 'nowrap'
}
function removeCloneTruncation(clone: HTMLElement) {
for (const node of clone.querySelectorAll<HTMLElement>('.truncate')) {
node.style.overflow = 'visible'
node.style.textOverflow = 'clip'
node.style.whiteSpace = 'nowrap'
node.style.width = 'max-content'
node.style.minWidth = 'max-content'
node.style.maxWidth = 'none'
}
}
function measureNaturalTitleWidth(title: HTMLDivElement): number {
const titleContent = title.querySelector('.breadcrumb-bar__title-content')
if (!(titleContent instanceof HTMLElement)) return readElementWidth(title)
const clone = titleContent.cloneNode(true) as HTMLElement
prepareTitleMeasurementClone(clone)
removeCloneTruncation(clone)
title.appendChild(clone)
const width = readElementWidth(clone)
clone.remove()
return width
}
function expandedActionsLeft(actions: HTMLDivElement, expandedActionsWidth: number): number {
const actionsRight = actions.getBoundingClientRect().right
return actionsRight - expandedActionsWidth
}
function shouldCollapseBreadcrumbOverflow(
title: HTMLDivElement,
actions: HTMLDivElement,
expandedActionsWidth: number,
) {
const titleLeft = title.getBoundingClientRect().left
const availableTitleWidth = expandedActionsLeft(actions, expandedActionsWidth) - titleLeft - TITLE_ACTION_GAP_PX
return measureNaturalTitleWidth(title) > availableTitleWidth
}
function useBreadcrumbOverflow(
titleRef: React.RefObject<HTMLDivElement | null>,
actionsRef: React.RefObject<HTMLDivElement | null>,
) {
const [collapsed, setCollapsed] = useState(false)
const expandedActionsWidthRef = useRef(0)
useLayoutEffect(() => {
const title = titleRef.current
const actions = actionsRef.current
const bar = title?.closest('.breadcrumb-bar') as HTMLDivElement | null
if (!title || !actions || !bar) return undefined
let frame = 0
const measure = () => {
const expandedActionsWidth = measureExpandedActionsWidth(actions, collapsed, expandedActionsWidthRef.current)
if (!collapsed) expandedActionsWidthRef.current = expandedActionsWidth
setCollapsed(shouldCollapseBreadcrumbOverflow(title, actions, expandedActionsWidth))
}
const scheduleMeasure = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(measure)
}
scheduleMeasure()
if (typeof ResizeObserver === 'undefined') {
return () => cancelAnimationFrame(frame)
}
const resizeObserver = new ResizeObserver(scheduleMeasure)
resizeObserver.observe(bar)
resizeObserver.observe(title)
resizeObserver.observe(actions)
return () => {
cancelAnimationFrame(frame)
resizeObserver.disconnect()
}
})
return collapsed
}
function normalizeFilenameStemInput(value: string): string {
const trimmed = value.trim()
return trimmed.replace(/\.md$/i, '').trim()
@@ -475,12 +635,10 @@ function FilenameInput({
}
function FilenameTrigger({
entry,
filenameStem,
locale = 'en',
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
locale?: AppLocale
onStartEditing: () => void
@@ -502,8 +660,7 @@ function FilenameTrigger({
data-testid="breadcrumb-filename-trigger"
aria-label={translate(locale, 'editor.filename.trigger', { filename: filenameStem })}
>
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
<span className="truncate">{filenameStem}</span>
<span className="breadcrumb-bar__filename-text truncate">{filenameStem}</span>
</Button>
)
}
@@ -554,7 +711,7 @@ function FilenameDisplay({
}) {
return (
<div className="flex min-w-0 items-center gap-1">
<FilenameTrigger entry={entry} filenameStem={filenameStem} locale={locale} onStartEditing={onStartEditing} />
<FilenameTrigger filenameStem={filenameStem} locale={locale} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} locale={locale} onRenameFilename={onRenameFilename} />
</div>
)
@@ -648,30 +805,149 @@ function BreadcrumbActions({
onDelete,
onArchive,
onUnarchive,
actionsRef,
overflowCollapsed,
locale = 'en',
}: Omit<BreadcrumbBarProps, 'wordCount' | 'barRef' | 'onRenameFilename'>) {
}: Omit<BreadcrumbBarProps, 'wordCount' | 'barRef' | 'onRenameFilename'> & {
actionsRef: React.RefObject<HTMLDivElement | null>
overflowCollapsed: boolean
}) {
return (
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
<div
ref={actionsRef}
className="breadcrumb-bar__actions ml-auto flex shrink-0 items-center"
data-overflow-collapsed={overflowCollapsed}
style={{ gap: 8 }}
>
<FavoriteAction favorite={entry.favorite} locale={locale} onToggleFavorite={onToggleFavorite} />
<OrganizedAction organized={entry.organized} locale={locale} onToggleOrganized={onToggleOrganized} />
<DiffAction
<OverflowToolbarAction>
<DiffAction
showDiffToggle={showDiffToggle}
diffMode={diffMode}
diffLoading={diffLoading}
onToggleDiff={onToggleDiff}
locale={locale}
/>
</OverflowToolbarAction>
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
<OverflowToolbarAction>
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
</OverflowToolbarAction>
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<OverflowToolbarAction>
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
</OverflowToolbarAction>
<OverflowToolbarAction>
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
</OverflowToolbarAction>
<OverflowToolbarAction>
<DeleteAction locale={locale} onDelete={onDelete} />
</OverflowToolbarAction>
<BreadcrumbOverflowMenu
entry={entry}
showDiffToggle={showDiffToggle}
diffMode={diffMode}
diffLoading={diffLoading}
onToggleDiff={onToggleDiff}
noteWidth={noteWidth}
onToggleNoteWidth={onToggleNoteWidth}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onArchive={onArchive}
onUnarchive={onUnarchive}
onDelete={onDelete}
locale={locale}
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
<NoteWidthAction noteWidth={noteWidth} locale={locale} onToggleNoteWidth={onToggleNoteWidth} />
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction locale={locale} onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} locale={locale} onToggleInspector={onToggleInspector} />
</div>
)
}
function BreadcrumbOverflowMenu({
entry,
showDiffToggle,
diffMode,
diffLoading,
onToggleDiff,
noteWidth,
onToggleNoteWidth,
onRevealFile,
onCopyFilePath,
onArchive,
onUnarchive,
onDelete,
locale = 'en',
}: Pick<
BreadcrumbBarProps,
| 'entry'
| 'showDiffToggle'
| 'diffMode'
| 'diffLoading'
| 'onToggleDiff'
| 'noteWidth'
| 'onToggleNoteWidth'
| 'onRevealFile'
| 'onCopyFilePath'
| 'onArchive'
| 'onUnarchive'
| 'onDelete'
| 'locale'
>) {
const runDiffAction = availableDiffAction(showDiffToggle, onToggleDiff)
const runRevealAction = pathAction(onRevealFile, entry.path)
const runCopyPathAction = pathAction(onCopyFilePath, entry.path)
const runArchiveAction = archiveAction(entry.archived, onArchive, onUnarchive)
const diffLabel = translate(locale, diffMenuLabelKey({ showDiffToggle, diffMode, diffLoading }))
const noteWidthLabel = translate(locale, noteWidthLabelKey(noteWidth))
const archiveLabel = translate(locale, archiveLabelKey(entry.archived))
return (
<DropdownMenu>
<ActionTooltip copy={{ label: translate(locale, 'editor.toolbar.moreActions') }} side="bottom" align="end">
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="breadcrumb-bar__overflow-menu text-muted-foreground hover:text-foreground"
aria-label={translate(locale, 'editor.toolbar.moreActions')}
data-testid="breadcrumb-overflow-menu-trigger"
>
<DotsThree size={18} weight="bold" className={BREADCRUMB_ICON_CLASS} />
</Button>
</DropdownMenuTrigger>
</ActionTooltip>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuItem disabled={!runDiffAction} onSelect={runDiffAction}>
<GitBranch size={16} />
{diffLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!onToggleNoteWidth} onSelect={onToggleNoteWidth}>
<NoteWidthMenuIcon noteWidth={noteWidth} />
{noteWidthLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!runRevealAction} onSelect={runRevealAction}>
<FolderOpen size={16} />
{translate(locale, 'editor.toolbar.revealFile')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!runCopyPathAction} onSelect={runCopyPathAction}>
<ClipboardText size={16} />
{translate(locale, 'editor.toolbar.copyFilePath')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!runArchiveAction} onSelect={runArchiveAction}>
<ArchiveMenuIcon archived={entry.archived} />
{archiveLabel}
</DropdownMenuItem>
<DropdownMenuItem disabled={!onDelete} variant="destructive" onSelect={onDelete}>
<Trash size={16} />
{translate(locale, 'editor.toolbar.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function BreadcrumbTitle({
entry,
locale,
@@ -680,7 +956,7 @@ function BreadcrumbTitle({
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'loadingTitle' | 'onRenameFilename'>) {
const typeLabel = entry.isA ?? 'Note'
return (
<div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<div className="breadcrumb-bar__title-content flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground">
<span className="shrink-0">{typeLabel}</span>
<span className="shrink-0 text-border"></span>
<div className="flex min-w-0 items-center gap-1 truncate">
@@ -701,6 +977,9 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
...actionProps
}: BreadcrumbBarProps) {
const { onMouseDown } = useDragRegion()
const actionsRef = useRef<HTMLDivElement | null>(null)
const titleRef = useRef<HTMLDivElement | null>(null)
const overflowCollapsed = useBreadcrumbOverflow(titleRef, actionsRef)
return (
<TooltipProvider>
@@ -713,11 +992,11 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
style={{
height: 52,
background: 'var(--background)',
padding: '6px 16px',
padding: '6px 16px 6px var(--breadcrumb-bar-left-padding, 16px)',
boxSizing: 'border-box',
}}
>
<div className="breadcrumb-bar__title min-w-0">
<div ref={titleRef} className="breadcrumb-bar__title min-w-0 flex-1 overflow-hidden">
<BreadcrumbTitle
entry={entry}
locale={locale}
@@ -728,9 +1007,15 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
<div
aria-hidden="true"
data-tauri-drag-region
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
className="breadcrumb-bar__drag-spacer w-6 shrink-0"
/>
<BreadcrumbActions
actionsRef={actionsRef}
entry={entry}
locale={locale}
overflowCollapsed={overflowCollapsed}
{...actionProps}
/>
<BreadcrumbActions entry={entry} locale={locale} {...actionProps} />
</div>
</TooltipProvider>
)

View File

@@ -67,4 +67,22 @@ describe('BreadcrumbBar filename visibility', () => {
expect(editorCss).toContain('padding: 0;')
expect(editorCss).toContain('border-radius: 0;')
})
it('offsets the editor-only breadcrumb title past the macOS traffic lights', () => {
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
expect(editorCss).toContain('.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar')
expect(editorCss).toContain('--breadcrumb-bar-left-padding: 90px;')
})
it('moves lower-priority breadcrumb actions into the overflow menu from measured overflow state', () => {
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
expect(editorCss).not.toContain('@container (max-width:')
expect(editorCss).toContain(".breadcrumb-bar__actions[data-overflow-collapsed='true']")
expect(editorCss).toContain('.breadcrumb-bar__overflowable-action')
expect(editorCss).toContain('display: none;')
expect(editorCss).toContain('.breadcrumb-bar__overflow-menu')
expect(editorCss).toContain('display: flex;')
})
})

View File

@@ -399,6 +399,7 @@ function OpenCommandPalette({
return (
<div
data-command-palette="true"
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
onClick={onClose}
>

View File

@@ -3,6 +3,8 @@ import { describe, it, expect, vi } from 'vitest'
import { CreateViewDialog } from './CreateViewDialog'
import type { ViewDefinition } from '../types'
const DIALOG_TEST_TIMEOUT_MS = 10_000
describe('CreateViewDialog', () => {
const defaultProps = {
open: true,
@@ -26,7 +28,7 @@ describe('CreateViewDialog', () => {
render(<CreateViewDialog {...defaultProps} />)
expect(screen.getByText('Create View')).toBeInTheDocument()
expect(screen.getByText('Create')).toBeInTheDocument()
})
}, DIALOG_TEST_TIMEOUT_MS)
it('shows "Edit View" title when editingView is provided', () => {
render(<CreateViewDialog {...defaultProps} editingView={makeEditingView()} />)

View File

@@ -25,9 +25,15 @@
/* Breadcrumb bar: border can still react to the data attribute, but the
breadcrumb filename/title stays visible at all times. */
.breadcrumb-bar {
container-type: inline-size;
--breadcrumb-bar-left-padding: 16px;
transition: border-color 0.2s ease;
}
.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar {
--breadcrumb-bar-left-padding: 90px;
}
.breadcrumb-bar[data-title-hidden] {
border-bottom-color: var(--border);
}
@@ -48,6 +54,18 @@
background: transparent;
}
.breadcrumb-bar__overflow-menu {
display: none;
}
.breadcrumb-bar__actions[data-overflow-collapsed='true'] .breadcrumb-bar__overflowable-action {
display: none;
}
.breadcrumb-bar__actions[data-overflow-collapsed='true'] .breadcrumb-bar__overflow-menu {
display: flex;
}
/* Scroll area wrapping title + editor — single scroll context for alignment */
.editor-scroll-area {
flex: 1;
@@ -104,16 +122,16 @@
matches our vault theme instead of BlockNote's hardcoded light/dark defaults. */
--bn-colors-editor-background: var(--bg-primary);
--bn-colors-editor-text: var(--text-primary);
--bn-colors-menu-background: var(--bg-card);
--bn-colors-menu-background: var(--surface-popover);
--bn-colors-menu-text: var(--text-primary);
--bn-colors-tooltip-background: var(--bg-hover);
--bn-colors-tooltip-background: var(--state-hover-subtle);
--bn-colors-tooltip-text: var(--text-primary);
--bn-colors-hovered-background: var(--bg-hover);
--bn-colors-hovered-background: var(--state-hover);
--bn-colors-hovered-text: var(--text-primary);
--bn-colors-selected-background: var(--bg-selected);
--bn-colors-selected-text: var(--text-primary);
--bn-colors-border: var(--border-primary);
--bn-colors-shadow: var(--border-primary);
--bn-colors-border: var(--border-dialog);
--bn-colors-shadow: var(--shadow-dialog);
--bn-colors-side-menu: var(--text-muted);
}
@@ -122,6 +140,84 @@
padding: 20px 0;
}
.editor__blocknote-container .bn-suggestion-menu {
border: 1px solid var(--border-dialog) !important;
background: var(--surface-popover) !important;
box-shadow: 0 8px 32px var(--shadow-dialog) !important;
}
.editor__blocknote-container .bn-suggestion-menu-label {
color: var(--text-secondary) !important;
}
.editor__blocknote-container .bn-suggestion-menu-item {
color: var(--text-primary) !important;
height: 34px !important;
min-height: 34px !important;
padding: 3px 8px !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="left"] {
width: 28px;
min-width: 28px;
margin-inline-end: 8px !important;
padding: 0 !important;
background: transparent !important;
border-radius: 0 !important;
color: var(--text-primary) !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="right"] {
margin-inline-start: 12px !important;
color: var(--text-muted) !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="right"] .mantine-Badge-root {
min-width: 0 !important;
height: auto !important;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
color: var(--text-muted) !important;
font-size: 10px !important;
font-weight: 500 !important;
line-height: 1.2 !important;
text-transform: none !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-body {
justify-content: center !important;
padding-right: 0 !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-subtitle {
display: none !important;
}
.editor__blocknote-container .tolaria-slash-menu-icon {
position: relative;
display: inline-flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
}
.editor__blocknote-container .tolaria-slash-menu-icon__fill {
position: absolute;
inset: 0;
opacity: 0;
}
.editor__blocknote-container .bn-suggestion-menu-item:is(:hover, [aria-selected="true"]) .tolaria-slash-menu-icon__regular {
opacity: 0;
}
.editor__blocknote-container .bn-suggestion-menu-item:is(:hover, [aria-selected="true"]) .tolaria-slash-menu-icon__fill {
opacity: 1;
}
/* =============================================
Bear-style live preview: zero horizontal shift
============================================= */

View File

@@ -153,6 +153,7 @@ import {
import type { VaultEntry } from '../types'
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
import { TooltipProvider } from '@/components/ui/tooltip'
import { clearParsedNoteBlockCache } from '../hooks/editorParsedBlockCache'
type EditorComponentProps = ComponentProps<typeof Editor>
@@ -235,6 +236,7 @@ describe('Editor', () => {
beforeEach(() => {
blockNoteCreation.options = []
blockNoteViewState.onChange = null
clearParsedNoteBlockCache()
})
it('shows empty state when no tabs are open', () => {
@@ -722,6 +724,57 @@ describe('Editor', () => {
resetVaultConfigStore()
})
it('opens raw mode from unchanged rich content without rewriting pasted markdown source', async () => {
resetVaultConfigStore()
bindVaultConfigStore(
{
zoom: null,
view_mode: null,
editor_mode: null,
tag_colors: null,
status_colors: null,
property_display_modes: null,
inbox: null,
},
vi.fn(),
)
const rawToggleRef = { current: (() => {}) as () => void }
const sourceContent = '---\ntitle: Pasted\n---\nFirst pasted line\nSecond pasted line\n'
const pastedTab = { entry: mockEntry, content: sourceContent }
const originalMarkdownSerializer = mockEditor.blocksToMarkdownLossy.getMockImplementation()
mockEditor.blocksToMarkdownLossy.mockReturnValue('First pasted line\\\\\n\\\\\nSecond pasted line\n')
try {
render(
<Editor
{...defaultProps}
tabs={[pastedTab]}
activeTabPath={mockEntry.path}
entries={[mockEntry]}
rawToggleRef={rawToggleRef}
/>,
)
await vi.waitFor(() => {
expect(typeof rawToggleRef.current).toBe('function')
})
await act(async () => {
await rawToggleRef.current()
})
await vi.waitFor(() => {
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('First pasted line')
})
expect(screen.getByTestId('raw-editor-codemirror').textContent).toContain('Second pasted line')
expect(screen.getByTestId('raw-editor-codemirror').textContent).not.toContain('\\\\')
} finally {
mockEditor.blocksToMarkdownLossy.mockImplementation(originalMarkdownSerializer)
resetVaultConfigStore()
}
})
})
describe('applyPendingRawExitContent', () => {

View File

@@ -17,6 +17,7 @@ import { useDragRegion } from '../hooks/useDragRegion'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { EditorMemoryProbe } from './EditorMemoryProbe'
import { FilePreview } from './FilePreview'
import { schema } from './editorSchema'
import type { RawEditorFindRequest } from './RawEditorFindBar'
@@ -188,6 +189,7 @@ function useEditorSetup({
rawToggleRef, diffToggleRef,
}: EditorSetupParams) {
const vaultPathRef = useRef(vaultPath)
const flushPendingEditorChangeRef = useRef<(() => boolean) | null>(null)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
const editor = useCreateBlockNote({
@@ -211,6 +213,7 @@ function useEditorSetup({
activeTab?.content ?? null,
onContentChange,
vaultPath,
flushPendingEditorChangeRef,
)
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
@@ -226,6 +229,14 @@ function useEditorSetup({
const { handleEditorChange, flushPendingEditorChange, editorMountedRef } = useEditorTabSwap({
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode, vaultPath,
})
useEffect(() => {
flushPendingEditorChangeRef.current = flushPendingEditorChange
return () => {
if (flushPendingEditorChangeRef.current === flushPendingEditorChange) {
flushPendingEditorChangeRef.current = null
}
}
}, [flushPendingEditorChange])
useEditorFocus(editor, editorMountedRef)
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({
@@ -513,6 +524,7 @@ function EditorLayout({
locale={locale}
/>
</div>
<EditorMemoryProbe entries={entries} vaultPath={vaultPath} locale={locale} />
</div>
)
}
@@ -538,7 +550,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
isConflicted, onKeepMine, onKeepTheirs,
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale,
} = props
const {
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
@@ -560,7 +571,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
handleToggleRawExclusive,
rawMode,
})
useRegisterEditorContentFlushes({
activeTab,
flushPendingEditorChange,
@@ -570,7 +580,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
onContentChange,
flushPendingRawContentRef,
})
return (
<EditorLayout
tabs={tabs}

View File

@@ -0,0 +1,82 @@
import { useEffect } from 'react'
import type { AppLocale } from '../lib/i18n'
import type { VaultEntry } from '../types'
import { HiddenEditorMemoryProbe } from './HiddenEditorMemoryProbe'
import type { EditorMemoryProbeApi } from './editorMemoryProbeTypes'
import { useEditorMemoryProbeController } from './useEditorMemoryProbeController'
declare global {
interface Window {
__tolariaEditorMemoryProbe?: EditorMemoryProbeApi
}
}
function useEditorMemoryProbeBridge(api: EditorMemoryProbeApi): void {
useEffect(() => {
if (!import.meta.env.DEV) return
window.__tolariaEditorMemoryProbe = api
return () => {
if (window.__tolariaEditorMemoryProbe?.run === api.run) {
delete window.__tolariaEditorMemoryProbe
}
}
}, [api])
}
function useEditorMemoryProbeShortcut(runAndCopy: EditorMemoryProbeApi['runAndCopy']): void {
useEffect(() => {
if (!import.meta.env.DEV) return
const handleKeyDown = (event: KeyboardEvent) => {
if (!event.metaKey || !event.altKey || !event.shiftKey || event.code !== 'KeyM') return
event.preventDefault()
event.stopPropagation()
void runAndCopy()
}
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
}, [runAndCopy])
}
export function EditorMemoryProbe({
entries,
locale,
vaultPath,
}: {
entries: VaultEntry[]
locale?: AppLocale
vaultPath?: string
}) {
const controller = useEditorMemoryProbeController(entries)
useEditorMemoryProbeBridge(controller)
useEditorMemoryProbeShortcut(controller.runAndCopy)
if (!import.meta.env.DEV || controller.targets.length === 0) return null
return (
<div
aria-hidden="true"
style={{
height: 1,
left: -100_000,
opacity: 0,
overflow: 'hidden',
pointerEvents: 'none',
position: 'fixed',
top: 0,
width: 1,
zIndex: -1,
}}
>
{controller.targets.map(target => (
<HiddenEditorMemoryProbe
key={target.entry.path}
entries={entries}
locale={locale}
onReady={controller.handleReady}
target={target}
vaultPath={vaultPath}
/>
))}
</div>
)
}

View File

@@ -32,11 +32,19 @@
so they fit within a single text line for headings and paragraphs. */
.editor__blocknote-container .bn-side-menu {
--group-wrap: nowrap !important;
align-items: center !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
gap: 0 !important;
}
.editor__blocknote-container .bn-side-menu button,
.editor__blocknote-container .tolaria-block-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
}
.editor__blocknote-container .bn-side-menu [draggable="true"] * {
pointer-events: none;
}
@@ -389,9 +397,29 @@
cursor: pointer;
}
/* Code blocks intentionally keep BlockNote's dark shell and syntax-highlighted content. */
/* Code blocks follow Tolaria's theme instead of BlockNote's fixed dark shell. */
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] {
background-color: var(--editor-code-block-background) !important;
border: 1px solid var(--editor-code-block-border);
color: var(--editor-code-block-text) !important;
}
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] > pre {
color: inherit;
}
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] > div > select {
color: var(--editor-code-block-language) !important;
}
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] > div > select > option {
background: var(--popover);
color: var(--foreground);
}
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code {
background-color: transparent;
color: inherit;
padding: 0;
border-radius: 0;
}
@@ -400,6 +428,11 @@
background-color: transparent !important;
}
:root:not(.dark) .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code .shiki,
[data-theme="light"] .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code .shiki {
color: var(--editor-code-block-text) !important;
}
/* --- Blockquote --- */
.editor__blocknote-container [data-content-type="blockquote"],
.editor__blocknote-container blockquote {

View File

@@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { FolderTree } from './FolderTree'
import { FOLDER_ROW_SINGLE_CLICK_DELAY_MS } from './folder-tree/useFolderRowInteractions'
import { FOLDER_ROW_NESTING_INDENT, getFolderConnectorLeft } from './folder-tree/folderTreeLayout'
import type { FolderNode, SidebarSelection } from '../types'
const mockFolders: FolderNode[] = [
@@ -67,7 +68,8 @@ describe('FolderTree', () => {
expect(screen.getByText('Laputa')).toBeInTheDocument()
})
it('lets the vault root collapse and expand its nested folders', () => {
it('lets the vault root collapse and expand from the row', () => {
vi.useFakeTimers()
render(
<FolderTree
folders={mockFolders}
@@ -77,19 +79,31 @@ describe('FolderTree', () => {
/>,
)
fireEvent.click(screen.getByLabelText('Collapse Laputa'))
fireEvent.click(screen.getByTestId('folder-row:'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.queryByText('projects')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Expand Laputa'))
fireEvent.click(screen.getByTestId('folder-row:'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.getByText('projects')).toBeInTheDocument()
vi.useRealTimers()
})
it('expands children when clicking the folder chevron', () => {
it('expands children when clicking a folder row', () => {
vi.useFakeTimers()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Expand projects'))
fireEvent.click(screen.getByTestId('folder-row:projects'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.getByText('laputa')).toBeInTheDocument()
expect(screen.getByText('portfolio')).toBeInTheDocument()
vi.useRealTimers()
})
it('calls onSelect with folder kind when clicking a folder row', () => {
@@ -182,32 +196,24 @@ describe('FolderTree', () => {
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
})
it('shows inline rename and delete actions for folders', () => {
const onDeleteFolder = vi.fn()
const onStartRenameFolder = vi.fn()
const onSelect = vi.fn()
it('keeps rename and delete out of row hover actions', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={onSelect}
onDeleteFolder={onDeleteFolder}
onSelect={vi.fn()}
onDeleteFolder={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
onStartRenameFolder={onStartRenameFolder}
onStartRenameFolder={vi.fn()}
onCancelRenameFolder={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('rename-folder-btn:projects'))
fireEvent.click(screen.getByTestId('delete-folder-btn:projects'))
expect(onSelect).toHaveBeenNthCalledWith(1, { kind: 'folder', path: 'projects' })
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
expect(onSelect).toHaveBeenNthCalledWith(2, { kind: 'folder', path: 'projects' })
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
expect(screen.queryByTestId('rename-folder-btn:projects')).not.toBeInTheDocument()
expect(screen.queryByTestId('delete-folder-btn:projects')).not.toBeInTheDocument()
})
it('does not reserve a disclosure slot for leaf folders', () => {
it('does not render folder-level disclosure buttons', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
const leafRowContainer = screen.getByTestId('folder-row:areas').parentElement
@@ -216,7 +222,23 @@ describe('FolderTree', () => {
expect(leafRowContainer).not.toBeNull()
expect(parentRowContainer).not.toBeNull()
expect(within(leafRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1)
expect(within(parentRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(2)
expect(within(parentRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1)
expect(screen.queryByLabelText('Expand projects')).not.toBeInTheDocument()
})
it('aligns nested folders with the parent folder name and centers connectors on parent icons', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
vaultRootPath={vaultRootPath}
/>,
)
expect(screen.getByTestId('folder-row:').parentElement).toHaveStyle({ paddingLeft: '0px' })
expect(screen.getByTestId('folder-row:projects').parentElement).toHaveStyle({ paddingLeft: `${FOLDER_ROW_NESTING_INDENT}px` })
expect(screen.getByTestId('folder-connector:')).toHaveStyle({ left: `${getFolderConnectorLeft(0)}px` })
})
it('shows the rename input when a folder is being renamed', () => {
@@ -287,6 +309,21 @@ describe('FolderTree', () => {
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
})
it('dismisses the folder context menu on Escape', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
onDeleteFolder={vi.fn()}
onStartRenameFolder={vi.fn()}
/>,
)
fireEvent.contextMenu(screen.getByText('projects'))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByTestId('folder-context-menu')).not.toBeInTheDocument()
})
it('opens folder file actions from the context menu', () => {
const onRevealFolder = vi.fn()
const onCopyFolderPath = vi.fn()

View File

@@ -0,0 +1,10 @@
import { useEffect } from 'react'
import { markFrontendReady } from '@/utils/frontendReady'
export function FrontendReadyMarker() {
useEffect(() => {
markFrontendReady()
}, [])
return null
}

View File

@@ -0,0 +1,74 @@
import { useEffect } from 'react'
import { useCreateBlockNote } from '@blocknote/react'
import { uploadImageFile } from '../hooks/useImageDrop'
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import type { AppLocale } from '../lib/i18n'
import type { VaultEntry } from '../types'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
import { createMathInputExtension } from './mathInputExtension'
import { schema } from './editorSchema'
import type { ProbeTarget } from './editorMemoryProbeTypes'
import { SingleEditorView } from './SingleEditorView'
function useProbeEditor(target: ProbeTarget, vaultPath?: string) {
const editor = useCreateBlockNote({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPath),
_tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE },
extensions: [createArrowLigaturesExtension(), createMathInputExtension()],
})
useEditorTabSwap({
tabs: [{ entry: target.entry, content: target.content }],
activeTabPath: target.entry.path,
editor,
rawMode: false,
vaultPath,
})
return editor
}
function useProbeReadySignal(target: ProbeTarget, onReady: (path: string) => void): void {
useEffect(() => {
const handleSwap = (event: Event) => {
const detail = (event as CustomEvent<{ path?: string }>).detail
if (detail?.path === target.entry.path) onReady(target.entry.path)
}
window.addEventListener('laputa:editor-tab-swapped', handleSwap)
return () => window.removeEventListener('laputa:editor-tab-swapped', handleSwap)
}, [onReady, target.entry.path])
}
export function HiddenEditorMemoryProbe({
entries,
locale,
onReady,
target,
vaultPath,
}: {
entries: VaultEntry[]
locale?: AppLocale
onReady: (path: string) => void
target: ProbeTarget
vaultPath?: string
}) {
const editor = useProbeEditor(target, vaultPath)
useProbeReadySignal(target, onReady)
return (
<div
aria-hidden="true"
data-editor-memory-probe-path={target.entry.path}
style={{ height: 900, overflow: 'hidden', width: 900 }}
>
<SingleEditorView
editor={editor}
entries={entries}
onNavigateWikilink={() => {}}
editable={false}
vaultPath={vaultPath}
locale={locale}
/>
</div>
)
}

View File

@@ -129,6 +129,26 @@ describe('Inspector', () => {
expect(onToggle).toHaveBeenCalledOnce()
})
it('shows a colliding-properties warning that opens the raw editor', async () => {
const onToggleRawEditor = vi.fn()
const content = `---
type: Note
status: Active
Status: Evergreened
---
# Test Project
`
renderSelectedInspector({ content, onToggleRawEditor })
const warning = screen.getByRole('button', { name: 'Colliding properties. Open raw editor.' })
fireEvent.focus(warning)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Colliding properties')
fireEvent.click(warning)
expect(onToggleRawEditor).toHaveBeenCalledOnce()
})
it('shows properties when a note is selected', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
expect(screen.getAllByText('Project').length).toBeGreaterThan(0)

View File

@@ -2,7 +2,7 @@ import { useMemo } from 'react'
import type { VaultEntry, GitCommit } from '../types'
import { cn } from '@/lib/utils'
import { Separator } from './ui/separator'
import { parseFrontmatter, detectFrontmatterState } from '../utils/frontmatter'
import { parseFrontmatter, detectFrontmatterState, detectFrontmatterWarnings } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import {
DynamicRelationshipsPanel,
@@ -247,9 +247,20 @@ function InspectorBody({
}
export function Inspector({ collapsed, onToggle, ...bodyProps }: InspectorProps) {
const frontmatterWarnings = useMemo(
() => detectFrontmatterWarnings(bodyProps.content),
[bodyProps.content],
)
return (
<aside className={cn('flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground transition-[width] duration-200', collapsed && '!w-10 !min-w-10')}>
<InspectorHeader collapsed={collapsed} locale={bodyProps.locale} onToggle={onToggle} />
<InspectorHeader
collapsed={collapsed}
frontmatterWarnings={frontmatterWarnings}
locale={bodyProps.locale}
onToggle={onToggle}
onOpenRawEditor={bodyProps.onToggleRawEditor}
/>
{!collapsed && (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-3">
<InspectorBody {...bodyProps} />

View File

@@ -2,6 +2,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LinuxMenuButton } from './LinuxMenuButton'
const MENU_TEST_TIMEOUT_MS = 10_000
const { close, invoke, minimize, toggleMaximize } = vi.hoisted(() => ({
invoke: vi.fn().mockResolvedValue(undefined),
minimize: vi.fn().mockResolvedValue(undefined),
@@ -32,11 +34,16 @@ describe('LinuxMenuButton', () => {
it('dispatches shared menu commands from the Linux menu', async () => {
render(<LinuxMenuButton />)
await openSubmenu('Edit')
expect(screen.getByText('Ctrl+Shift+V')).toBeInTheDocument()
fireEvent.click(await screen.findByText('Paste without Formatting'))
expect(invoke).toHaveBeenCalledWith('trigger_menu_command', { id: 'edit-paste-plain-text' })
await openSubmenu('Note')
expect(screen.getByText('Ctrl+Shift+L')).toBeInTheDocument()
fireEvent.click(await screen.findByText('Toggle AI Panel'))
expect(invoke).toHaveBeenCalledWith('trigger_menu_command', { id: 'view-toggle-ai-chat' })
})
}, MENU_TEST_TIMEOUT_MS)
it('invokes direct window actions from the Window submenu', async () => {
render(<LinuxMenuButton />)

View File

@@ -1,7 +1,6 @@
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import type { AppCommandId } from '../hooks/appCommandCatalog'
import { APP_COMMAND_DEFINITIONS, APP_COMMAND_IDS } from '../hooks/appCommandCatalog'
import { APP_COMMAND_MENU_SECTIONS } from '../hooks/appCommandCatalog'
import { Button } from './ui/button'
import {
DropdownMenu,
@@ -17,7 +16,13 @@ import {
type MenuItem =
| { kind: 'separator' }
| { kind: 'command'; commandId: MenuCommandId; label: string }
| {
kind: 'command'
commandId: string
label: string
menuItemId: string
shortcut?: string
}
| { kind: 'action'; action: () => void; label: string; shortcut?: string }
type MenuSection = {
@@ -25,90 +30,8 @@ type MenuSection = {
label: string
}
type MenuCommandId = AppCommandId | 'edit-toggle-note-list-search'
const MENU_SECTIONS: ReadonlyArray<MenuSection> = [
{
label: 'File',
items: [
{ kind: 'command', label: 'New Note', commandId: APP_COMMAND_IDS.fileNewNote },
{ kind: 'command', label: 'New Type', commandId: APP_COMMAND_IDS.fileNewType },
{ kind: 'command', label: 'Quick Open', commandId: APP_COMMAND_IDS.fileQuickOpen },
{ kind: 'separator' },
{ kind: 'command', label: 'Save', commandId: APP_COMMAND_IDS.fileSave },
],
},
{
label: 'Edit',
items: [
{ kind: 'command', label: 'Find in Note', commandId: APP_COMMAND_IDS.editFindInNote },
{ kind: 'command', label: 'Replace in Note', commandId: APP_COMMAND_IDS.editReplaceInNote },
{ kind: 'command', label: 'Find in Vault', commandId: APP_COMMAND_IDS.editFindInVault },
{ kind: 'command', label: 'Toggle Note List Search', commandId: 'edit-toggle-note-list-search' },
{ kind: 'command', label: 'Toggle Diff Mode', commandId: APP_COMMAND_IDS.editToggleDiff },
],
},
{
label: 'View',
items: [
{ kind: 'command', label: 'Editor Only', commandId: APP_COMMAND_IDS.viewEditorOnly },
{ kind: 'command', label: 'Editor + Notes', commandId: APP_COMMAND_IDS.viewEditorList },
{ kind: 'command', label: 'All Panels', commandId: APP_COMMAND_IDS.viewAll },
{ kind: 'separator' },
{ kind: 'command', label: 'Toggle Properties Panel', commandId: APP_COMMAND_IDS.viewToggleProperties },
{ kind: 'separator' },
{ kind: 'command', label: 'Zoom In', commandId: APP_COMMAND_IDS.viewZoomIn },
{ kind: 'command', label: 'Zoom Out', commandId: APP_COMMAND_IDS.viewZoomOut },
{ kind: 'command', label: 'Actual Size', commandId: APP_COMMAND_IDS.viewZoomReset },
{ kind: 'separator' },
{ kind: 'command', label: 'Command Palette', commandId: APP_COMMAND_IDS.viewCommandPalette },
],
},
{
label: 'Go',
items: [
{ kind: 'command', label: 'All Notes', commandId: APP_COMMAND_IDS.goAllNotes },
{ kind: 'command', label: 'Archived', commandId: APP_COMMAND_IDS.goArchived },
{ kind: 'command', label: 'Changes', commandId: APP_COMMAND_IDS.goChanges },
{ kind: 'command', label: 'Inbox', commandId: APP_COMMAND_IDS.goInbox },
{ kind: 'separator' },
{ kind: 'command', label: 'Go Back', commandId: APP_COMMAND_IDS.viewGoBack },
{ kind: 'command', label: 'Go Forward', commandId: APP_COMMAND_IDS.viewGoForward },
],
},
{
label: 'Note',
items: [
{ kind: 'command', label: 'Toggle Organized', commandId: APP_COMMAND_IDS.noteToggleOrganized },
{ kind: 'command', label: 'Archive Note', commandId: APP_COMMAND_IDS.noteArchive },
{ kind: 'command', label: 'Delete Note', commandId: APP_COMMAND_IDS.noteDelete },
{ kind: 'command', label: 'Restore Deleted Note', commandId: APP_COMMAND_IDS.noteRestoreDeleted },
{ kind: 'separator' },
{ kind: 'command', label: 'Open in New Window', commandId: APP_COMMAND_IDS.noteOpenInNewWindow },
{ kind: 'separator' },
{ kind: 'command', label: 'Toggle Raw Editor', commandId: APP_COMMAND_IDS.editToggleRawEditor },
{ kind: 'command', label: 'Toggle AI Panel', commandId: APP_COMMAND_IDS.viewToggleAiChat },
{ kind: 'command', label: 'Toggle Backlinks', commandId: APP_COMMAND_IDS.viewToggleBacklinks },
],
},
{
label: 'Vault',
items: [
{ kind: 'command', label: 'Open Vault…', commandId: APP_COMMAND_IDS.vaultOpen },
{ kind: 'command', label: 'Remove Vault from List', commandId: APP_COMMAND_IDS.vaultRemove },
{ kind: 'command', label: 'Restore Getting Started', commandId: APP_COMMAND_IDS.vaultRestoreGettingStarted },
{ kind: 'separator' },
{ kind: 'command', label: 'Add Remote…', commandId: APP_COMMAND_IDS.vaultAddRemote },
{ kind: 'command', label: 'Commit & Push', commandId: APP_COMMAND_IDS.vaultCommitPush },
{ kind: 'command', label: 'Pull from Remote', commandId: APP_COMMAND_IDS.vaultPull },
{ kind: 'command', label: 'Resolve Conflicts', commandId: APP_COMMAND_IDS.vaultResolveConflicts },
{ kind: 'command', label: 'View Pending Changes', commandId: APP_COMMAND_IDS.vaultViewChanges },
{ kind: 'separator' },
{ kind: 'command', label: 'Reload Vault', commandId: APP_COMMAND_IDS.vaultReload },
{ kind: 'command', label: 'Repair Vault', commandId: APP_COMMAND_IDS.vaultRepair },
{ kind: 'command', label: 'Set Up External AI Tools…', commandId: APP_COMMAND_IDS.vaultInstallMcp },
],
},
...APP_COMMAND_MENU_SECTIONS,
{
label: 'Window',
items: [
@@ -120,31 +43,8 @@ const MENU_SECTIONS: ReadonlyArray<MenuSection> = [
},
]
function formatShortcutKey(key: string): string {
switch (key) {
case 'ArrowLeft':
return '←'
case 'ArrowRight':
return '→'
default:
return key.length === 1 ? key.toUpperCase() : key
}
}
function getLinuxShortcut(commandId: MenuCommandId): string | null {
if (commandId === 'edit-toggle-note-list-search') {
return 'Ctrl+F'
}
const shortcut = APP_COMMAND_DEFINITIONS[commandId].shortcut
if (!shortcut) return null
const modifier = shortcut.combo === 'command-or-ctrl' ? 'Ctrl' : 'Ctrl+Shift'
return `${modifier}+${formatShortcutKey(shortcut.key)}`
}
function triggerMenuCommand(commandId: MenuCommandId): void {
void invoke('trigger_menu_command', { id: commandId }).catch(() => {})
function triggerMenuCommand(menuItemId: string): void {
void invoke('trigger_menu_command', { id: menuItemId }).catch(() => {})
}
function HamburgerIcon() {
@@ -183,15 +83,14 @@ export function LinuxMenuButton() {
}
if (item.kind === 'command') {
const shortcut = getLinuxShortcut(item.commandId)
return (
<DropdownMenuItem
key={item.commandId}
onSelect={() => triggerMenuCommand(item.commandId)}
key={item.menuItemId}
onSelect={() => triggerMenuCommand(item.menuItemId)}
>
<span>{item.label}</span>
{shortcut && (
<DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut>
{item.shortcut && (
<DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>
)}
</DropdownMenuItem>
)

View File

@@ -24,6 +24,7 @@ const {
}))
vi.mock('../utils/platform', () => ({
isMac: () => false,
shouldUseLinuxWindowChrome: vi.fn(),
}))

View File

@@ -65,6 +65,33 @@ describe('McpSetupDialog', () => {
expect(screen.getByTestId('mcp-setup-disconnect')).toHaveTextContent('Disconnect')
})
it('keeps overflowing setup content inside a scrollable modal body', () => {
render(
<McpSetupDialog
open={true}
status="not_installed"
busyAction={null}
manualConfigSnippet={MANUAL_CONFIG}
onClose={vi.fn()}
onConnect={vi.fn()}
onDisconnect={vi.fn()}
/>,
)
expect(screen.getByTestId('mcp-setup-dialog')).toHaveClass(
'flex',
'max-h-[calc(100dvh-2rem)]',
'overflow-hidden',
)
expect(screen.getByTestId('mcp-setup-scroll-body')).toHaveClass(
'min-h-0',
'flex-1',
'overflow-y-auto',
'overscroll-contain',
)
expect(screen.getByTestId('mcp-setup-actions')).toHaveClass('shrink-0')
})
it('routes actions through the dialog buttons', () => {
const onClose = vi.fn()
const onConnect = vi.fn()

View File

@@ -121,7 +121,10 @@ function McpSetupActions({
const t = createTranslator(locale)
return (
<DialogFooter className="flex-row items-center justify-end gap-2 sm:justify-end">
<DialogFooter
className="shrink-0 flex-row flex-wrap items-center justify-end gap-2 sm:justify-end"
data-testid="mcp-setup-actions"
>
<Button type="button" variant="outline" onClick={onClose} disabled={buttonsDisabled}>
{t('common.cancel')}
</Button>
@@ -175,8 +178,12 @@ export function McpSetupDialog({
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]" data-testid="mcp-setup-dialog">
<DialogHeader>
<DialogContent
showCloseButton={false}
className="flex max-h-[calc(100dvh-2rem)] flex-col overflow-hidden sm:max-w-[520px]"
data-testid="mcp-setup-dialog"
>
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<ShieldCheck size={18} />
{copy.title}
@@ -184,7 +191,10 @@ export function McpSetupDialog({
<DialogDescription>{copy.description}</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
<div
className="min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain pr-1 text-sm leading-6 text-muted-foreground"
data-testid="mcp-setup-scroll-body"
>
<p>
{t('mcp.setup.nodeRequirement')}
</p>

View File

@@ -94,6 +94,33 @@ describe('NoteItem', () => {
expect(onClickNote).toHaveBeenCalled()
})
it('uses CSS named colors from the Type document for note type indicators', () => {
const ideaType = makeEntry({
path: '/vault/type/idea.md',
filename: 'idea.md',
title: 'Idea',
isA: 'Type',
color: 'cyan',
})
const ideaEntry = makeEntry({
path: '/vault/ideas/native-cyan-idea.md',
filename: 'native-cyan-idea.md',
title: 'Native Cyan Idea',
isA: 'Idea',
})
render(
<NoteItem
entry={ideaEntry}
isSelected={false}
typeEntryMap={{ Idea: ideaType }}
onClickNote={vi.fn()}
/>,
)
expect(screen.getByTestId('type-icon')).toHaveStyle({ color: 'rgb(0, 255, 255)' })
})
it('shows the title with filename metadata when a change status is present', () => {
const entry = {
...makeEntry({ filename: 'my-note.md', title: 'My Note Title' }),
@@ -199,18 +226,18 @@ describe('NoteItem', () => {
})
it('colors relationship chips by target type and opens the related note on Cmd+click only', () => {
const linkedProject = makeEntry({
path: '/vault/project/build-app.md',
const linkedIdea = makeEntry({
path: '/vault/ideas/build-app.md',
filename: 'build-app.md',
title: 'Build App',
isA: 'Project',
isA: 'Idea',
})
const projectType = makeEntry({
path: '/vault/type/project.md',
filename: 'project.md',
title: 'Project',
const ideaType = makeEntry({
path: '/vault/type/idea.md',
filename: 'idea.md',
title: 'Idea',
isA: 'Type',
color: 'red',
color: 'cyan',
icon: 'wrench',
})
const sourceEntry = makeEntry({
@@ -218,7 +245,7 @@ describe('NoteItem', () => {
filename: 'source.md',
title: 'Source',
isA: 'Note',
relationships: { 'Belongs to': ['[[project/build-app]]'] },
relationships: { 'Belongs to': ['[[ideas/build-app]]'] },
})
const onClickNote = vi.fn()
@@ -226,8 +253,8 @@ describe('NoteItem', () => {
<NoteItem
entry={sourceEntry}
isSelected={false}
typeEntryMap={{ Project: projectType }}
allEntries={[sourceEntry, linkedProject, projectType]}
typeEntryMap={{ Idea: ideaType }}
allEntries={[sourceEntry, linkedIdea, ideaType]}
displayPropsOverride={['Belongs to']}
onClickNote={onClickNote}
/>,
@@ -236,13 +263,14 @@ describe('NoteItem', () => {
const chip = screen.getByTestId('property-chip-belongs-to-0')
expect(chip).toHaveTextContent('Build App')
expect(chip.className).toContain('cursor-pointer')
expect(chip).toHaveStyle({ color: 'var(--accent-red)', backgroundColor: 'var(--accent-red-light)' })
expect(chip).toHaveStyle({ color: 'rgb(0, 255, 255)' })
expect(chip.getAttribute('style')).toContain('background-color: color-mix(in srgb, cyan 14%, transparent)')
fireEvent.click(chip)
expect(onClickNote).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(onClickNote).toHaveBeenCalledWith(linkedProject, expect.objectContaining({ metaKey: true }))
expect(onClickNote).toHaveBeenCalledWith(linkedIdea, expect.objectContaining({ metaKey: true }))
})
it('falls back to the built-in type icon for relationship chips when the Type has no custom icon', () => {

View File

@@ -328,7 +328,7 @@ type NoteItemProps = {
allEntries?: VaultEntry[]
displayPropsOverride?: string[] | null
onClickNote: (entry: VaultEntry, e: ReactMouseEvent) => void
onPrefetch?: (path: string) => void
onPrefetch?: (entry: VaultEntry) => void
onContextMenu?: (entry: VaultEntry, e: ReactMouseEvent) => void
}
@@ -409,7 +409,7 @@ function resolveNoteItemSurfaceProps({
style: resolveNoteItemSurfaceStyle({ isUnavailableBinary, isSelected, isMultiSelected, typeColor, typeLightColor }),
onClick: createNoteItemClickHandler(entry, isUnavailableBinary, onClickNote),
onContextMenu: onContextMenu ? (event) => onContextMenu(entry, event) : undefined,
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry.path) : undefined,
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry) : undefined,
testId: resolveNoteItemTestId({ isMultiSelected, previewKind, isUnavailableBinary }),
title: resolveNoteItemTitle({ previewKind, isUnavailableBinary }),
}

View File

@@ -115,6 +115,6 @@ describe('NoteList keyboard activation', () => {
fireEvent.mouseEnter(noteRow!)
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1].path)
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1])
})
})

View File

@@ -76,6 +76,7 @@ vi.mock('./NoteSearchList', () => ({
}))
import { RawEditorView } from './RawEditorView'
import { insertPlainTextFromClipboardText } from '../utils/plainTextPaste'
function entry(title: string, path = `/vault/note/${title}.md`) {
return {
@@ -112,6 +113,10 @@ function createMockView(docText = '[[Target') {
state: {
doc: { toString: () => docText },
selection: { main: { head: docText.length } },
replaceSelection: vi.fn((text: string) => ({
changes: { from: 2, to: 5, insert: text },
selection: { anchor: 2 + text.length },
})),
},
dispatch: vi.fn(),
focus: vi.fn(),
@@ -313,4 +318,30 @@ describe('RawEditorView behavior coverage', () => {
expect(callbacks.onEscape()).toBe(true)
})
it('handles registered plain-text paste requests with CodeMirror selection replacement', () => {
const mockView = createMockView('Alpha Beta')
viewRefState.current = mockView
render(
<RawEditorView
content="Alpha Beta"
path="/vault/a.md"
entries={[entry('Alpha')]}
onContentChange={vi.fn()}
onSave={vi.fn()}
/>,
)
fireEvent.focus(screen.getByTestId('raw-editor-codemirror'))
expect(insertPlainTextFromClipboardText('Plain\nText')).toBe(true)
expect(mockView.state.replaceSelection).toHaveBeenCalledWith('Plain\nText')
expect(mockView.dispatch).toHaveBeenCalledWith({
changes: { from: 2, to: 5, insert: 'Plain\nText' },
selection: { anchor: 12 },
userEvent: 'input.paste',
})
expect(mockView.focus).toHaveBeenCalledOnce()
})
})

View File

@@ -17,6 +17,11 @@ import { useCodeMirror } from '../hooks/useCodeMirror'
import type { VaultEntry } from '../types'
import { translate, type AppLocale } from '../lib/i18n'
import { RawEditorFindBar, type RawEditorFindRequest } from './RawEditorFindBar'
import {
activatePlainTextPasteTarget,
registerPlainTextPasteTarget,
type PlainTextPasteTarget,
} from '../utils/plainTextPaste'
export interface RawEditorViewProps {
content: string
@@ -334,6 +339,53 @@ function useRawEditorWikilinkInsertion({
useEffect(() => { insertWikilinkRef.current = insertAutocompleteWikilink }, [insertAutocompleteWikilink, insertWikilinkRef])
}
function useRawEditorPlainTextPasteTarget({
containerRef,
setAutocomplete,
viewRef,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
setAutocomplete: RawEditorSetAutocomplete
viewRef: React.MutableRefObject<EditorView | null>
}) {
const targetRef = useRef<PlainTextPasteTarget | null>(null)
useEffect(() => {
const target: PlainTextPasteTarget = {
surface: 'raw_editor',
contains: (element) => Boolean(element && containerRef.current?.contains(element)),
isConnected: () => containerRef.current?.isConnected === true,
insert: (text) => {
const view = viewRef.current
if (!view) return false
view.dispatch({
...view.state.replaceSelection(text),
userEvent: 'input.paste',
})
setAutocomplete(null)
view.focus()
return true
},
}
targetRef.current = target
const unregister = registerPlainTextPasteTarget(target)
return () => {
unregister()
if (targetRef.current === target) {
targetRef.current = null
}
}
}, [containerRef, setAutocomplete, viewRef])
return useCallback(() => {
if (targetRef.current) {
activatePlainTextPasteTarget(targetRef.current)
}
}, [])
}
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en', findRequest }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [rawDoc, setRawDoc] = useState(content)
@@ -366,6 +418,11 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
onSave: pendingChanges.handleSave,
onEscape: handleEscape,
})
const activatePlainTextPaste = useRawEditorPlainTextPasteTarget({
containerRef,
setAutocomplete,
viewRef,
})
useRawEditorWikilinkInsertion({
debounceRef: pendingChanges.debounceRef,
@@ -391,7 +448,14 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window)
return (
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
<div
className="flex flex-1 flex-col min-h-0 relative"
style={{ background: 'var(--background)' }}
onFocusCapture={activatePlainTextPaste}
onKeyDown={handleAutocompleteKey}
onMouseDownCapture={activatePlainTextPaste}
role="presentation"
>
<RawEditorYamlErrorBanner error={pendingChanges.yamlError} />
<RawEditorFindBar
doc={rawDoc}

View File

@@ -711,7 +711,7 @@ describe('Sidebar', () => {
wordCount: 0,
relationships: {},
icon: null,
color: 'orange',
color: 'cyan',
order: null,
sidebarLabel: null,
template: null, sort: null,
@@ -750,7 +750,7 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByTitle('Customize sections'))
expect(screen.getByLabelText('Toggle Projects').querySelector('svg')).toHaveStyle({ color: 'var(--accent-green)' })
expect(screen.getByLabelText('Toggle Recipes').querySelector('svg')).toHaveStyle({ color: 'var(--accent-orange)' })
expect(screen.getByLabelText('Toggle Recipes').querySelector('svg')).toHaveStyle({ color: 'rgb(0, 255, 255)' })
})
it('calls onToggleTypeVisibility when toggling a section in the popover', () => {
@@ -1170,7 +1170,7 @@ describe('Sidebar', () => {
const favoritesHeader = screen.getByText('FAVORITES').closest('div') as HTMLElement
const countChip = within(favoritesHeader).getByTestId('sidebar-count-chip')
expect(favoritesHeader).toHaveStyle({ padding: '8px 8px 8px 16px' })
expect(favoritesHeader).toHaveStyle({ padding: '8px 8px 8px 12px' })
expect(countChip).toHaveStyle({
background: 'var(--muted)',
height: '18px',
@@ -1290,6 +1290,16 @@ describe('Sidebar', () => {
expect(screen.getByText('Delete view')).toBeInTheDocument()
})
it('opens and dismisses the View context menu from the keyboard', () => {
renderViewActions()
const row = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]')!
fireEvent.keyDown(row, { key: 'F10', shiftKey: true })
expect(screen.getByText('Edit view')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByText('Edit view')).not.toBeInTheDocument()
})
it('calls onEditView from the context menu', () => {
const onEditView = vi.fn()
renderViewActions({ onEditView })
@@ -1431,7 +1441,7 @@ describe('Sidebar', () => {
const navItem = viewLabel.closest('[class*="cursor-pointer"]') as HTMLElement
const countChip = within(navItem).getByTestId('view-count-chip')
expect(navItem).toHaveStyle({ padding: '6px 8px 6px 16px' })
expect(navItem).toHaveStyle({ padding: '6px 8px 6px 12px' })
expect(countChip).toHaveStyle({
background: 'var(--muted)',
height: '20px',
@@ -1450,8 +1460,8 @@ describe('Sidebar', () => {
const viewItem = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]') as HTMLElement
const viewCount = within(viewItem).getByTestId('view-count-chip')
expect(topNavItem).toHaveStyle({ padding: '6px 8px 6px 16px' })
expect(viewItem).toHaveStyle({ padding: '6px 8px 6px 16px' })
expect(topNavItem).toHaveStyle({ padding: '6px 8px 6px 12px' })
expect(viewItem).toHaveStyle({ padding: '6px 8px 6px 12px' })
expect(topNavCount).toHaveStyle({
background: 'var(--muted)',
height: '20px',

View File

@@ -68,6 +68,10 @@ interface SidebarProps {
allNotesFileVisibility?: AllNotesFileVisibility
locale?: AppLocale
onCollapse?: () => void
onGoBack?: () => void
onGoForward?: () => void
canGoBack?: boolean
canGoForward?: boolean
loading?: boolean
}
@@ -631,7 +635,14 @@ export const Sidebar = memo(function Sidebar(props: SidebarProps) {
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<SidebarTitleBar locale={locale} onCollapse={props.onCollapse} />
<SidebarTitleBar
locale={locale}
onCollapse={props.onCollapse}
onGoBack={props.onGoBack}
onGoForward={props.onGoForward}
canGoBack={props.canGoBack}
canGoForward={props.canGoForward}
/>
<SidebarRuntimeNavigation props={props} runtime={runtime} />
<SidebarInteractionOverlays locale={locale} runtime={runtime} />
</aside>

View File

@@ -80,6 +80,12 @@ describe('Sidebar Type row actions', () => {
expect(screen.getByText('Delete type')).toBeInTheDocument()
})
it('dismisses the type context menu on Escape', () => {
openProjectsContextMenu()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByText('Rename type…')).not.toBeInTheDocument()
})
it('calls onDeleteType from the context menu', () => {
const onDeleteType = vi.fn()
renderSidebar({ onDeleteType })

View File

@@ -1,10 +1,12 @@
import { type ComponentType, useState, useEffect, useRef } from 'react'
import { type ComponentType } from 'react'
import type { SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
import { SIDEBAR_ITEM_PADDING } from './sidebar/sidebarStyles'
import { useSidebarInlineRenameInput } from './sidebar/sidebarHooks'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { translate, type AppLocale } from '../lib/i18n'
const SIDEBAR_COUNT_PILL_STYLE = {
@@ -336,27 +338,28 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel, locale }: {
onCancel: () => void
locale?: AppLocale
}) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onSubmit(value.trim()) }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel() }
}
const {
handleKeyDown,
inputRef,
setValue,
submitValue,
value,
} = useSidebarInlineRenameInput({
initialValue,
onCancel,
onSubmit: (nextValue) => onSubmit(nextValue.trim()),
})
return (
<input
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSubmit(value.trim())}
onBlur={() => { void submitValue() }}
onClick={(e) => e.stopPropagation()}
aria-label={translate(locale ?? 'en', 'sidebar.section.name')}
className="flex-1 rounded border border-primary bg-background text-[13px] font-medium text-foreground outline-none"
style={{ padding: '1px 4px' }}
className="h-auto min-h-0 flex-1 rounded border-primary bg-background px-1 py-px text-[13px] font-medium text-foreground"
/>
)
}
@@ -488,7 +491,7 @@ function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, ite
return (
<div
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", !isActive && "hover:bg-accent")}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...getSectionHeaderBackground(isActive, sectionLightColor) }}
style={{ padding: SIDEBAR_ITEM_PADDING.withCount, borderRadius: 4, gap: 4, ...getSectionHeaderBackground(isActive, sectionLightColor) }}
{...dragHandleProps}
onClick={getSectionSelectHandler(isRenaming, onSelect)}
onContextMenu={getSectionContextMenuHandler(isRenaming, onContextMenu)}

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { VaultEntry } from '../types'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import { insertPlainTextFromClipboardText } from '../utils/plainTextPaste'
const state = vi.hoisted(() => ({
capturedLinkToolbarProps: null as null | Record<string, unknown>,
@@ -603,6 +604,18 @@ describe('SingleEditorView', () => {
expect(clipboardData.setData).not.toHaveBeenCalled()
})
it('handles registered plain-text paste requests through BlockNote insertion', () => {
const { container, editor } = renderEditorHarness()
fireEvent.focus(container)
expect(insertPlainTextFromClipboardText('Plain\nText')).toBe(true)
expect(editor.focus).toHaveBeenCalled()
expect(editor.insertInlineContent).toHaveBeenCalledWith('Plain\nText', {
updateSelection: true,
})
})
it('routes clicks on the empty title wrapper back into the H1 block', async () => {
const editor = createEditor()

View File

@@ -42,6 +42,11 @@ import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
import { useEditorLinkActivation } from './useEditorLinkActivation'
import { findNearestTextCursorBlock } from './blockNoteCursorTarget'
import { ImageLightbox } from './ImageLightbox'
import {
activatePlainTextPasteTarget,
registerPlainTextPasteTarget,
type PlainTextPasteTarget,
} from '../utils/plainTextPaste'
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
| --- | --- | --- |
@@ -575,6 +580,50 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
}, [])
}
function useRichEditorPlainTextPasteTarget(options: {
containerRef: React.RefObject<HTMLDivElement | null>
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
runEditorAction: (action: SuggestionAction) => void
}) {
const { containerRef, editable, editor, runEditorAction } = options
const targetRef = useRef<PlainTextPasteTarget | null>(null)
useEffect(() => {
const target: PlainTextPasteTarget = {
surface: 'rich_editor',
contains: (element) => Boolean(element && containerRef.current?.contains(element)),
isConnected: () => containerRef.current?.isConnected === true,
insert: (text) => {
if (!editable) return false
let inserted = false
runEditorAction(() => {
editor.focus()
editor.insertInlineContent(text, { updateSelection: true })
inserted = true
})
return inserted
},
}
targetRef.current = target
const unregister = registerPlainTextPasteTarget(target)
return () => {
unregister()
if (targetRef.current === target) {
targetRef.current = null
}
}
}, [containerRef, editable, editor, runEditorAction])
return useCallback(() => {
if (targetRef.current) {
activatePlainTextPasteTarget(targetRef.current)
}
}, [])
}
/** Single BlockNote editor view — content is swapped via replaceBlocks */
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, editable = true, locale = 'en' }: {
editor: ReturnType<typeof useCreateBlockNote>
@@ -617,6 +666,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
editor,
})
}, [editor])
const activatePlainTextPaste = useRichEditorPlainTextPasteTarget({
containerRef,
editable,
editor,
runEditorAction,
})
const insertWikilink = useInsertWikilink(editor, runEditorAction)
const {
getWikilinkItems,
@@ -632,7 +687,15 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
})
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick} onCopyCapture={handleCodeBlockCopy}>
<div
ref={containerRef}
className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`}
style={cssVars as React.CSSProperties}
onClick={handleContainerClick}
onCopyCapture={handleCodeBlockCopy}
onFocusCapture={activatePlainTextPaste}
onMouseDownCapture={activatePlainTextPaste}
>
{isDragOver && (
<div className="editor__drop-overlay">
<div className="editor__drop-overlay-label">Drop image here</div>

View File

@@ -110,7 +110,7 @@ describe('TypeCustomizePopover', () => {
it('calls onChangeColor when a color is clicked', () => {
renderPopover()
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i)
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|pink/i)
fireEvent.click(colorButtons[0])
expect(onChangeColor).toHaveBeenCalled()
@@ -123,6 +123,31 @@ describe('TypeCustomizePopover', () => {
expect(onChangeIcon).toHaveBeenCalledWith('wrench')
})
it('renders picker icons slightly larger than the sidebar icon size', () => {
renderPopover()
const icon = screen.getByTitle('wrench').querySelector('svg')
expect(icon).toHaveAttribute('width', '18')
expect(icon).toHaveAttribute('height', '18')
expect(icon).toHaveClass('size-[18px]')
})
it('orders color swatches by hue before neutral gray', () => {
renderPopover()
const colorRow = screen.getByTitle('Red').parentElement
expect(Array.from(colorRow?.children ?? []).map((element) => element.getAttribute('title'))).toEqual([
'Red',
'Orange',
'Yellow',
'Green',
'Blue',
'Purple',
'Pink',
'Gray',
])
})
it('calls onClose when Done is clicked', () => {
renderPopover()
@@ -130,10 +155,10 @@ describe('TypeCustomizePopover', () => {
expect(onClose).toHaveBeenCalled()
})
it('renders all color options including teal and pink', () => {
it('renders curated color options without the teal near-duplicate', () => {
renderPopover()
expect(screen.getByTitle('Teal')).toBeInTheDocument()
expect(screen.queryByTitle('Teal')).not.toBeInTheDocument()
expect(screen.getByTitle('Pink')).toBeInTheDocument()
})

View File

@@ -49,6 +49,20 @@ interface TemplateSectionProps {
onTemplateChange: (value: string) => void
}
const ICON_PICKER_ICON_SIZE = 18
const ICON_PICKER_ICON_CLASS_NAME = 'size-[18px]'
const COLOR_PICKER_ACCENT_COLORS = [
'red',
'orange',
'yellow',
'green',
'blue',
'purple',
'pink',
'gray',
].map((key) => ACCENT_COLORS.find((color) => color.key === key) ?? null)
.filter((color): color is typeof ACCENT_COLORS[number] => color !== null)
/** Debounce a callback by `delay` ms. Returns a stable ref-based wrapper. */
function useDebouncedCallback(fn: (v: string) => void, delay: number): (v: string) => void {
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
@@ -68,7 +82,7 @@ function ColorSection({ selectedColor, locale, onSelectColor }: ColorSectionProp
<>
<div className="font-mono-overline mb-2 text-muted-foreground">{translate(locale, 'customize.color')}</div>
<div className="flex gap-2 mb-3 flex-wrap">
{ACCENT_COLORS.map((color) => (
{COLOR_PICKER_ACCENT_COLORS.map((color) => (
<Button
key={color.key}
type="button"
@@ -118,7 +132,10 @@ function IconSection({
className="h-7 pl-7 pr-2 py-1 text-[12px]"
/>
</div>
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 160 }}>
<div
className="grid gap-1 overflow-y-auto"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(30px, 1fr))', maxHeight: 160 }}
>
{filteredIcons.length === 0 ? (
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
{translate(locale, 'customize.noIconsFound')}
@@ -131,7 +148,7 @@ function IconSection({
variant="ghost"
size="icon-xs"
className={cn(
'h-[30px] w-[30px] rounded p-0 transition-colors',
'h-[30px] w-[30px] justify-self-center rounded p-0 transition-colors',
selectedIcon === name
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -140,7 +157,7 @@ function IconSection({
title={name}
aria-label={name}
>
<Icon size={16} />
<Icon size={ICON_PICKER_ICON_SIZE} className={ICON_PICKER_ICON_CLASS_NAME} />
</Button>
))
)}

View File

@@ -1,5 +1,5 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TypeSelector } from './TypeSelector'
@@ -31,12 +31,17 @@ describe('TypeSelector', () => {
const trigger = screen.getByRole('combobox')
trigger.focus()
fireEvent.keyDown(trigger, { key: 'Enter' })
await act(async () => {
fireEvent.keyDown(trigger, { key: 'Enter' })
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
})
const searchInput = screen.getByTestId('type-selector-search-input')
await waitFor(() => expect(searchInput).toHaveFocus())
const searchInput = await screen.findByTestId('type-selector-search-input', {}, { timeout: 5_000 })
await waitFor(() => expect(searchInput).toHaveFocus(), { timeout: 5_000 })
expect(screen.getByRole('option', { name: 'Project' })).toHaveAttribute('aria-selected', 'true')
})
}, 10_000)
it('filters available types as the user types', () => {
renderTypeSelector()

View File

@@ -437,6 +437,26 @@ describe('WikilinkChatInput', () => {
expect(screen.queryByTestId('inline-wikilink-chip')).toBeNull()
})
it('lets native modified delete shortcuts reach the browser editor pipeline', () => {
const onDraftChange = vi.fn()
render(<Controlled onDraftChange={onDraftChange} />)
updateEditorText('delete the previous words')
onDraftChange.mockClear()
const editor = screen.getByTestId('agent-input')
const optionBackspace = createEvent.keyDown(editor, { key: 'Backspace', altKey: true })
fireEvent(editor, optionBackspace)
const commandBackspace = createEvent.keyDown(editor, { key: 'Backspace', metaKey: true })
fireEvent(editor, commandBackspace)
const controlDelete = createEvent.keyDown(editor, { key: 'Delete', ctrlKey: true })
fireEvent(editor, controlDelete)
expect(optionBackspace.defaultPrevented).toBe(false)
expect(commandBackspace.defaultPrevented).toBe(false)
expect(controlDelete.defaultPrevented).toBe(false)
expect(onDraftChange).not.toHaveBeenCalled()
})
it('submits serialized wikilink text and resolved references', () => {
const onSend = vi.fn()
render(<Controlled onSend={onSend} />)

View File

@@ -6,6 +6,9 @@ function createFixture() {
const transaction = { insertText: vi.fn(() => transaction) }
const paragraphNode = { type: { name: 'paragraph', spec: {} } }
const view = {
dom: {
isConnected: true,
},
dispatch: vi.fn(),
state: {
doc: {
@@ -149,4 +152,17 @@ describe('createArrowLigaturesExtension', () => {
expect(fixture.transaction.insertText).not.toHaveBeenCalled()
expect(fixture.view.dispatch).not.toHaveBeenCalled()
})
it('falls through when a reload leaves the ProseMirror view stale during beforeinput', () => {
const fixture = createFixture()
fixture.mount()
Object.defineProperty(fixture.view, 'state', {
configurable: true,
get: () => {
throw new Error('stale editor view')
},
})
expect(() => fixture.fireInput()).not.toThrow()
})
})

View File

@@ -4,6 +4,8 @@ import { resolveArrowLigatureInput } from '../utils/arrowLigatures'
const PREFIX_CONTEXT_LENGTH = 2
interface CodeContextSelection {
from?: unknown
to?: unknown
$from?: {
depth: number
node: (depth?: number) => {
@@ -15,6 +17,33 @@ interface CodeContextSelection {
}
}
interface ArrowLigatureView {
composing?: boolean
dom?: { isConnected?: boolean }
isDestroyed?: boolean
}
interface ArrowLigatureTransactionArgs<Transaction> {
event: InputEvent & { data: string }
literalAsciiCursor: number | null
view: {
state: {
doc: {
textBetween: (from: number, to: number, blockSeparator: string, leafText: string) => string
}
selection: CodeContextSelection
tr: {
insertText: (text: string, from: number, to: number) => Transaction
}
}
}
}
interface ArrowLigatureTransactionResult<Transaction> {
nextLiteralAsciiCursor: number | null
transaction: Transaction | null
}
function isInsertedCharacter(event: InputEvent): event is InputEvent & { data: string } {
return event.inputType === 'insertText' && typeof event.data === 'string'
}
@@ -31,8 +60,19 @@ function isCodeContext(selection: CodeContextSelection): boolean {
return false
}
function hasWritableCursor(selection: { from: number; to: number }): boolean {
return selection.from === selection.to
function getWritableCursor(selection: CodeContextSelection): number | null {
const { from, to } = selection
if (typeof from !== 'number' || typeof to !== 'number') return null
if (!Number.isFinite(from) || !Number.isFinite(to)) return null
return from === to ? from : null
}
function isLiveEditorView(view: ArrowLigatureView): boolean {
if (view.isDestroyed) return false
if (view.dom?.isConnected === false) return false
return true
}
function isComposingInput({
@@ -45,6 +85,51 @@ function isComposingInput({
return event.isComposing || Boolean(view.composing)
}
function withoutTransaction<Transaction>(
nextLiteralAsciiCursor: number | null,
): ArrowLigatureTransactionResult<Transaction> {
return { nextLiteralAsciiCursor, transaction: null }
}
function buildArrowLigatureTransaction<Transaction>({
event,
literalAsciiCursor,
view,
}: ArrowLigatureTransactionArgs<Transaction>): ArrowLigatureTransactionResult<Transaction> {
try {
const { state } = view
const { selection } = state
const from = getWritableCursor(selection)
if (from === null) return withoutTransaction(literalAsciiCursor)
if (isCodeContext(selection)) return withoutTransaction(null)
const beforeText = state.doc.textBetween(
Math.max(0, from - PREFIX_CONTEXT_LENGTH),
from,
'',
'',
)
const resolution = resolveArrowLigatureInput({
beforeText,
cursor: from,
inputText: event.data,
literalAsciiCursor,
})
if (!resolution.change) return withoutTransaction(resolution.nextLiteralAsciiCursor)
return {
nextLiteralAsciiCursor: resolution.nextLiteralAsciiCursor,
transaction: state.tr.insertText(
resolution.change.insert,
resolution.change.from,
resolution.change.to,
),
}
} catch {
return withoutTransaction(null)
}
}
export const createArrowLigaturesExtension = createExtension(({ editor }) => {
let literalAsciiCursor: number | null = null
@@ -57,45 +142,26 @@ export const createArrowLigaturesExtension = createExtension(({ editor }) => {
if (!view) {
return
}
if (!isLiveEditorView(view)) {
literalAsciiCursor = null
return
}
if (isComposingInput({ event, view })) {
return
}
const { from } = view.state.selection
if (!hasWritableCursor(view.state.selection)) {
const result = buildArrowLigatureTransaction({ event, literalAsciiCursor, view })
literalAsciiCursor = result.nextLiteralAsciiCursor
if (result.transaction === null) {
return
}
if (isCodeContext(view.state.selection)) {
try {
view.dispatch(result.transaction)
event.preventDefault()
} catch {
literalAsciiCursor = null
return
}
const beforeText = view.state.doc.textBetween(
Math.max(0, from - PREFIX_CONTEXT_LENGTH),
from,
'',
'',
)
const resolution = resolveArrowLigatureInput({
beforeText,
cursor: from,
inputText: event.data,
literalAsciiCursor,
})
literalAsciiCursor = resolution.nextLiteralAsciiCursor
if (!resolution.change) {
return
}
event.preventDefault()
view.dispatch(
view.state.tr.insertText(
resolution.change.insert,
resolution.change.from,
resolution.change.to,
),
)
}
return {

View File

@@ -361,7 +361,10 @@ function EditorCanvas({
if (!showEditor) return null
return (
<EditorFindScope className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<EditorFindScope
className="editor-scroll-area"
style={cssVars as React.CSSProperties}
>
<div className="editor-content-wrapper">
<SingleEditorView
editor={editor}

View File

@@ -0,0 +1,82 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
import type { VaultEntry } from '../types'
import type {
ProbeResult,
ProbeRunOptions,
ProbeTarget,
ProbeTargetSummary,
ProcessMemorySnapshot,
} from './editorMemoryProbeTypes'
export const DEFAULT_PROBE_LIMIT = 5
export const DEFAULT_PROBE_BATCH_SIZE = 1
export const DEFAULT_PROBE_SETTLE_MS = 700
export const PROBE_READY_TIMEOUT_MS = 30_000
function wait(ms: number): Promise<void> {
return new Promise(resolve => window.setTimeout(resolve, ms))
}
function contentBytes(content: string): number {
return new TextEncoder().encode(content).byteLength
}
export function summarizeTarget({ entry, content }: ProbeTarget): ProbeTargetSummary {
return {
path: entry.path,
fileSize: entry.fileSize,
contentBytes: contentBytes(content),
lineCount: content.split('\n').length,
}
}
export function memoryDelta(
snapshot: ProcessMemorySnapshot | null,
baseline: ProcessMemorySnapshot | null,
): number | null {
if (!snapshot || !baseline) return null
return snapshot.totalRssBytes - baseline.totalRssBytes
}
export function selectProbeEntries(entries: VaultEntry[], options: ProbeRunOptions): VaultEntry[] {
const markdownEntries = entries.filter(entry => (entry.fileKind ?? 'markdown') === 'markdown')
if (options.paths?.length) {
const wantedPaths = new Set(options.paths)
return markdownEntries.filter(entry => wantedPaths.has(entry.path))
}
return [...markdownEntries]
.sort((left, right) => right.fileSize - left.fileSize)
.slice(0, options.limit ?? DEFAULT_PROBE_LIMIT)
}
export function resolveMountCounts(targetCount: number, batchSize: number): number[] {
const counts: number[] = []
for (let count = batchSize; count < targetCount; count += batchSize) {
counts.push(count)
}
if (targetCount > 0) counts.push(targetCount)
return counts
}
export async function readMemorySnapshot(): Promise<ProcessMemorySnapshot | null> {
if (!isTauri()) return null
return invoke<ProcessMemorySnapshot>('get_process_memory_snapshot')
}
export async function loadProbeTarget(entry: VaultEntry): Promise<ProbeTarget> {
const content = await invoke<string>('get_note_content', { path: entry.path })
return { entry, content }
}
export async function copyProbeResult(result: ProbeResult): Promise<void> {
if (!isTauri()) return
await invoke('copy_text_to_clipboard', {
text: JSON.stringify(result, null, 2),
})
}
export function settleAfterMount(settleMs: number): Promise<void> {
return wait(settleMs).then(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve())))
}

View File

@@ -0,0 +1,61 @@
import type { VaultEntry } from '../types'
export interface ProbeTarget {
entry: VaultEntry
content: string
}
export interface ProcessMemoryEntry {
pid: number
parentPid: number
rssBytes: number
role: string
command: string
}
export interface ProcessMemorySnapshot {
currentPid: number
totalRssBytes: number
entries: ProcessMemoryEntry[]
}
export interface ProbeRunOptions {
paths?: string[]
limit?: number
batchSize?: number
settleMs?: number
}
export interface ProbeStep {
mountedCount: number
mountedPaths: string[]
snapshot: ProcessMemorySnapshot | null
deltaBytes: number | null
}
export interface ProbeTargetSummary {
path: string
fileSize: number
contentBytes: number
lineCount: number
}
export interface ProbeResult {
targets: ProbeTargetSummary[]
baseline: ProcessMemorySnapshot | null
afterContentLoad: ProcessMemorySnapshot | null
contentLoadDeltaBytes: number | null
steps: ProbeStep[]
}
export interface ProbeWaiter {
paths: Set<string>
resolve: () => void
timer: number
}
export interface EditorMemoryProbeApi {
run: (options?: ProbeRunOptions) => Promise<ProbeResult>
runAndCopy: (options?: ProbeRunOptions) => Promise<ProbeResult>
clear: () => void
}

View File

@@ -11,6 +11,23 @@ const mockEditor = {
}
describe('editorRawModeSync arrow ligatures', () => {
it('preserves source markdown when raw mode opens without pending rich-editor edits', () => {
const rawLatestContentRef = { current: null as string | null }
const sourceContent = '---\ntitle: Pasted\n---\nFirst pasted line\nSecond pasted line\n'
const result = syncActiveTabIntoRawBuffer({
editor: mockEditor as never,
activeTabPath: '/vault/pasted.md',
activeTabContent: sourceContent,
rawLatestContentRef,
serializeRichEditorContent: false,
})
expect(result).toBe(sourceContent)
expect(rawLatestContentRef.current).toBe(sourceContent)
expect(mockEditor.blocksToMarkdownLossy).not.toHaveBeenCalled()
})
it('keeps unicode arrows intact when rich-editor content enters raw mode', () => {
mockEditor.blocksToMarkdownLossy.mockReturnValueOnce('Flow → left ← both ↔\n')
const rawLatestContentRef = { current: null as string | null }
@@ -20,6 +37,7 @@ describe('editorRawModeSync arrow ligatures', () => {
activeTabPath: '/vault/flows.md',
activeTabContent: '---\ntitle: Flows\n---\n\nFlow -> left <- both <->\n',
rawLatestContentRef,
serializeRichEditorContent: true,
})
expect(result).toBe('---\ntitle: Flows\n---\nFlow → left ← both ↔\n')

View File

@@ -78,12 +78,22 @@ export function syncActiveTabIntoRawBuffer(options: {
activeTabPath: string | null
activeTabContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
serializeRichEditorContent?: boolean
vaultPath?: string
}) {
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, vaultPath } = options
const {
editor,
activeTabPath,
activeTabContent,
rawLatestContentRef,
serializeRichEditorContent = true,
vaultPath,
} = options
if (!activeTabPath || activeTabContent === null) return null
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent, vaultPath)
const syncedContent = serializeRichEditorContent
? serializeEditorDocumentToMarkdown(editor, activeTabContent, vaultPath)
: activeTabContent
rawLatestContentRef.current = syncedContent
return syncedContent
}

View File

@@ -1,17 +1,12 @@
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import {
CaretDown,
CaretRight,
Folder,
FolderOpen,
PencilSimple,
Trash,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { FolderNode } from '../../types'
import { useFolderRowInteractions } from './useFolderRowInteractions'
import { translate, type AppLocale } from '../../lib/i18n'
interface FolderItemRowProps {
contentInset: number
@@ -19,12 +14,10 @@ interface FolderItemRowProps {
isExpanded: boolean
isSelected: boolean
node: FolderNode
onDeleteFolder?: (folderPath: string) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
onSelect: () => void
onStartRenameFolder?: (folderPath: string) => void
onToggle: (path: string) => void
locale?: AppLocale
}
export function FolderItemRow({
@@ -33,16 +26,12 @@ export function FolderItemRow({
isExpanded,
isSelected,
node,
onDeleteFolder,
onOpenMenu,
onSelect,
onStartRenameFolder,
onToggle,
locale = 'en',
}: FolderItemRowProps) {
const hasChildren = node.children.length > 0
const expandLabel = translate(locale, isExpanded ? 'sidebar.folder.collapse' : 'sidebar.folder.expand', { name: node.name })
const hasActions = !!onStartRenameFolder || !!onDeleteFolder
const { handleRenameDoubleClick, handleSelectClick } = useFolderRowInteractions({
hasChildren,
onRenameFolder: onStartRenameFolder ? () => onStartRenameFolder(node.path) : undefined,
@@ -64,15 +53,8 @@ export function FolderItemRow({
onOpenMenu(node, event)
}}
>
<FolderToggleButton
expandLabel={expandLabel}
hasChildren={hasChildren}
isExpanded={isExpanded}
onToggle={() => onToggle(node.path)}
/>
<FolderSelectButton
contentInset={contentInset}
hasActions={hasActions}
hasChildren={hasChildren}
isExpanded={isExpanded}
isSelected={isSelected}
@@ -80,111 +62,12 @@ export function FolderItemRow({
onClick={handleSelectClick}
onDoubleClick={handleRenameDoubleClick}
/>
{hasActions && (
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onStartRenameFolder && (
<FolderActionButton
ariaLabel={translate(locale, 'sidebar.action.renameFolder')}
testId={`rename-folder-btn:${node.path}`}
title={translate(locale, 'sidebar.action.renameFolder')}
onClick={() => {
onSelect()
onStartRenameFolder(node.path)
}}
>
<PencilSimple size={12} />
</FolderActionButton>
)}
{onDeleteFolder && (
<FolderActionButton
ariaLabel={translate(locale, 'sidebar.action.deleteFolder')}
testId={`delete-folder-btn:${node.path}`}
title={translate(locale, 'sidebar.action.deleteFolder')}
destructive
onClick={() => {
onSelect()
onDeleteFolder(node.path)
}}
>
<Trash size={12} />
</FolderActionButton>
)}
</div>
)}
</div>
)
}
function FolderToggleButton({
expandLabel,
hasChildren,
isExpanded,
onToggle,
}: {
expandLabel: string
hasChildren: boolean
isExpanded: boolean
onToggle: () => void
}) {
if (!hasChildren) return null
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-4 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => {
event.stopPropagation()
onToggle()
}}
aria-label={expandLabel}
>
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</Button>
)
}
function FolderActionButton({
ariaLabel,
children,
destructive = false,
onClick,
testId,
title,
}: {
ariaLabel: string
children: ReactNode
destructive?: boolean
onClick: () => void
testId: string
title: string
}) {
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={ariaLabel}
title={title}
className={cn(
'h-5 w-5 rounded p-0 text-muted-foreground',
destructive ? 'hover:text-destructive' : 'hover:text-foreground',
)}
data-testid={testId}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
>
{children}
</Button>
)
}
function FolderSelectButton({
contentInset,
hasActions,
hasChildren,
isExpanded,
isSelected,
@@ -193,7 +76,6 @@ function FolderSelectButton({
onDoubleClick,
}: {
contentInset: number
hasActions: boolean
hasChildren: boolean
isExpanded: boolean
isSelected: boolean
@@ -212,10 +94,11 @@ function FolderSelectButton({
style={{
paddingTop: 6,
paddingBottom: 6,
paddingLeft: hasChildren ? 0 : contentInset,
paddingRight: hasActions ? 48 : 16,
paddingLeft: contentInset,
paddingRight: 16,
}}
title={node.path || node.name}
aria-expanded={hasChildren ? isExpanded : undefined}
onClick={(event) => onClick(event.detail)}
onDoubleClick={onDoubleClick}
data-testid={`folder-row:${node.path}`}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Folder } from '@phosphor-icons/react'
import { Input } from '@/components/ui/input'
import { useSidebarInlineRenameInput } from '../sidebar/sidebarHooks'
interface FolderNameInputProps {
ariaLabel: string
@@ -25,26 +25,18 @@ export function FolderNameInput({
onCancel,
onSubmit,
}: FolderNameInputProps) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
const submittingRef = useRef(false)
useEffect(() => {
const input = inputRef.current
if (!input) return
input.focus()
if (selectTextOnFocus) input.select()
}, [selectTextOnFocus])
const handleSubmit = useCallback(async () => {
if (submittingRef.current) return false
submittingRef.current = true
try {
return await onSubmit(value)
} finally {
submittingRef.current = false
}
}, [onSubmit, value])
const {
handleKeyDown,
inputRef,
setValue,
submitValue,
value,
} = useSidebarInlineRenameInput({
initialValue,
onCancel,
onSubmit,
selectTextOnFocus,
})
return (
<div className="flex items-center gap-2 rounded" style={{ paddingTop: 6, paddingBottom: 6, paddingRight: 16, paddingLeft: leftInset, borderRadius: 4 }}>
@@ -55,17 +47,8 @@ export function FolderNameInput({
className="h-auto min-h-0 flex-1 rounded-sm px-2 py-[3px] text-[13px] font-medium"
value={value}
onChange={(event) => setValue(event.target.value)}
onBlur={submitOnBlur ? () => { void handleSubmit() } : undefined}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void handleSubmit()
}
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}}
onBlur={submitOnBlur ? () => { void submitValue() } : undefined}
onKeyDown={handleKeyDown}
placeholder={placeholder}
data-testid={testId}
/>

View File

@@ -2,6 +2,7 @@ import { memo, useCallback, type MouseEvent as ReactMouseEvent } from 'react'
import type { FolderNode, SidebarSelection } from '../../types'
import { FolderNameInput } from './FolderNameInput'
import { FolderItemRow } from './FolderItemRow'
import { FOLDER_ROW_CONTENT_INSET, getFolderConnectorLeft, getFolderDepthIndent } from './folderTreeLayout'
import { translate, type AppLocale } from '../../lib/i18n'
interface FolderTreeRowProps {
@@ -73,10 +74,11 @@ function FolderChildren({
if (!isExpanded || !hasChildren) return null
return (
<div className="relative" style={{ paddingLeft: 15 }}>
<div className="relative" data-testid={`folder-children:${node.path}`}>
<div
className="absolute top-0 bottom-0 bg-border"
style={{ left: 15 + depth * 16, width: 1, opacity: 0.3 }}
data-testid={`folder-connector:${node.path}`}
style={{ left: getFolderConnectorLeft(depth), width: 1 }}
/>
{node.children.map((child) => (
<FolderTreeRow
@@ -121,8 +123,8 @@ export const FolderTreeRow = memo(function FolderTreeRow({
const isRenaming = renamingFolderPath === node.path
const isSelected = selection.kind === 'folder' && selection.path === node.path
const canMutateFolder = node.path.length > 0
const depthIndent = depth * 16
const contentInset = 16
const depthIndent = getFolderDepthIndent(depth)
const contentInset = FOLDER_ROW_CONTENT_INSET
const selectFolder = useCallback(() => {
onSelect(node.path === '' ? { kind: 'folder', path: '', rootPath } : { kind: 'folder', path: node.path })
}, [node.path, onSelect, rootPath])
@@ -133,12 +135,10 @@ export const FolderTreeRow = memo(function FolderTreeRow({
isExpanded={isExpanded}
isSelected={isSelected}
node={node}
onDeleteFolder={canMutateFolder ? onDeleteFolder : undefined}
onOpenMenu={onOpenMenu}
onSelect={selectFolder}
onStartRenameFolder={canMutateFolder ? onStartRenameFolder : undefined}
onToggle={onToggle}
locale={locale}
/>
)

View File

@@ -0,0 +1,17 @@
export const FOLDER_ROW_CONTENT_INSET = 12
export const FOLDER_ROW_ICON_SIZE = 17
export const FOLDER_ROW_ICON_GAP = 8
export const FOLDER_ROW_NESTING_INDENT = FOLDER_ROW_ICON_SIZE + FOLDER_ROW_ICON_GAP
const FOLDER_CONNECTOR_WIDTH = 1
export function getFolderDepthIndent(depth: number) {
return depth * FOLDER_ROW_NESTING_INDENT
}
export function getFolderConnectorLeft(depth: number) {
return FOLDER_ROW_CONTENT_INSET
+ getFolderDepthIndent(depth)
+ FOLDER_ROW_ICON_SIZE / 2
- FOLDER_CONNECTOR_WIDTH / 2
}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type RefObject } from 'react'
import { useCallback, type MouseEvent as ReactMouseEvent } from 'react'
import type { FolderNode } from '../../types'
import type { FolderFileActions } from '../../hooks/useFileActions'
import type { FolderContextMenuState } from './FolderContextMenu'
import { useSidebarContextMenu } from '../sidebar/sidebarHooks'
interface UseFolderContextMenuInput {
onDeleteFolder?: (folderPath: string) => void
@@ -9,50 +9,21 @@ interface UseFolderContextMenuInput {
onStartRenameFolder?: (folderPath: string) => void
}
function useContextMenuDismiss(
contextMenu: FolderContextMenuState | null,
menuRef: RefObject<HTMLDivElement | null>,
closeContextMenu: () => void,
) {
useEffect(() => {
if (!contextMenu) return
const handleOutsideClick = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) closeContextMenu()
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') closeContextMenu()
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleEscape)
}
}, [closeContextMenu, contextMenu, menuRef])
}
export function useFolderContextMenu({
onDeleteFolder,
folderFileActions,
onStartRenameFolder,
}: UseFolderContextMenuInput) {
const [contextMenu, setContextMenu] = useState<FolderContextMenuState | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
useContextMenuDismiss(contextMenu, menuRef, closeContextMenu)
const {
closeContextMenu,
contextMenu,
contextMenuRef,
openContextMenuFromPointer,
} = useSidebarContextMenu<string>()
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
setContextMenu({
path: node.path,
x: event.clientX,
y: event.clientY,
})
}, [])
openContextMenuFromPointer(node.path, event)
}, [openContextMenuFromPointer])
const handleRenameFromMenu = useCallback((folderPath: string) => {
closeContextMenu()
@@ -73,15 +44,20 @@ export function useFolderContextMenu({
closeContextMenu()
folderFileActions?.copyFolderPath(folderPath)
}, [closeContextMenu, folderFileActions])
const menu = contextMenu ? {
path: contextMenu.target,
x: contextMenu.pos.x,
y: contextMenu.pos.y,
} : null
return {
closeContextMenu,
contextMenu,
contextMenu: menu,
handleCopyPathFromMenu,
handleDeleteFromMenu,
handleOpenMenu,
handleRevealFromMenu,
handleRenameFromMenu,
menuRef,
menuRef: contextMenuRef,
}
}

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