Compare commits
56 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d86770abf | ||
|
|
e23ba0ae27 | ||
|
|
7a97f24a0e | ||
|
|
20f34ab9d1 | ||
|
|
19b31cc96a | ||
|
|
e5ed863d5d | ||
|
|
4f7f5a31e4 | ||
|
|
439e2b7f66 | ||
|
|
048d27243d | ||
|
|
58e129cdbc | ||
|
|
713b486750 | ||
|
|
7d66e2f8e1 | ||
|
|
cd6847d59e | ||
|
|
74c9841ad7 | ||
|
|
ba41854f0e | ||
|
|
6532ee125a | ||
|
|
c9698b853e | ||
|
|
db16f2ff5e | ||
|
|
1e5d83d4a3 | ||
|
|
062de2656e | ||
|
|
65d14ebf6f | ||
|
|
0b5149ba8d | ||
|
|
c23a3c1caf | ||
|
|
44e2870697 | ||
|
|
802b6b77d9 | ||
|
|
bb09fcfd6f | ||
|
|
c8ac12f3dc | ||
|
|
fbfda2f15c | ||
|
|
c4c7737e83 | ||
|
|
c24c60b433 | ||
|
|
21532de941 | ||
|
|
f92efd72df | ||
|
|
6e41328f21 | ||
|
|
5b2b4cd569 | ||
|
|
494aeb9533 | ||
|
|
8272e68934 | ||
|
|
7ba20c1e83 | ||
|
|
3384b9acd0 | ||
|
|
8b36ff6b00 | ||
|
|
acb35cb5df | ||
|
|
a8bda0a11b | ||
|
|
4acbe17e1e | ||
|
|
09759d204c | ||
|
|
7a01ef6952 | ||
|
|
1c010ddc22 | ||
|
|
0df5c7882d | ||
|
|
2d138f1564 | ||
|
|
c9ee81c5dc | ||
|
|
04fa74e8d8 | ||
|
|
b42ed35596 | ||
|
|
75e1a82ff0 | ||
|
|
2af2156526 | ||
|
|
568ada8992 | ||
|
|
0987946cff | ||
|
|
81b03f05b6 | ||
|
|
ee71a00c6f |
@@ -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
137
.github/scripts/prefetch-tauri-nsis.ps1
vendored
Normal 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."
|
||||
10
.github/workflows/release-stable.yml
vendored
10
.github/workflows/release-stable.yml
vendored
@@ -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
|
||||
|
||||
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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+1–9 | 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()`
|
||||
|
||||
@@ -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
|
||||
|
||||
48
docs/adr/0104-tauri-frontend-readiness-watchdog.md
Normal file
48
docs/adr/0104-tauri-frontend-readiness-watchdog.md
Normal 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.
|
||||
@@ -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.
|
||||
31
docs/adr/0106-shared-app-command-manifest.md
Normal file
31
docs/adr/0106-shared-app-command-manifest.md
Normal 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.
|
||||
32
docs/adr/0107-pointer-owned-editor-block-reordering.md
Normal file
32
docs/adr/0107-pointer-owned-editor-block-reordering.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
44
index.html
44
index.html
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
16
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
167
src-tauri/src/commands/memory.rs
Normal file
167
src-tauri/src/commands/memory.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
178
src-tauri/src/frontmatter/keys.rs
Normal file
178
src-tauri/src/frontmatter/keys.rs
Normal 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()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod keys;
|
||||
mod ops;
|
||||
#[cfg(test)]
|
||||
mod ops_update_tests;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
284
src-tauri/src/linux_appimage.rs
Normal file
284
src-tauri/src/linux_appimage.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 §ion.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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"trafficLightPosition": {
|
||||
"x": 18,
|
||||
"y": 24
|
||||
},
|
||||
"hiddenTitle": true,
|
||||
"backgroundColor": "#F7F6F3",
|
||||
"dragDropEnabled": true
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
56
src/App.tsx
56
src/App.tsx
@@ -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)} />
|
||||
|
||||
55
src/components/AiPanelChrome.performance.test.tsx
Normal file
55
src/components/AiPanelChrome.performance.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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()} />)
|
||||
|
||||
@@ -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
|
||||
============================================= */
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
82
src/components/EditorMemoryProbe.tsx
Normal file
82
src/components/EditorMemoryProbe.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
10
src/components/FrontendReadyMarker.tsx
Normal file
10
src/components/FrontendReadyMarker.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import { markFrontendReady } from '@/utils/frontendReady'
|
||||
|
||||
export function FrontendReadyMarker() {
|
||||
useEffect(() => {
|
||||
markFrontendReady()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
74
src/components/HiddenEditorMemoryProbe.tsx
Normal file
74
src/components/HiddenEditorMemoryProbe.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 />)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
}))
|
||||
|
||||
vi.mock('../utils/platform', () => ({
|
||||
isMac: () => false,
|
||||
shouldUseLinuxWindowChrome: vi.fn(),
|
||||
}))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
@@ -115,6 +115,6 @@ describe('NoteList keyboard activation', () => {
|
||||
|
||||
fireEvent.mouseEnter(noteRow!)
|
||||
|
||||
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1].path)
|
||||
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
82
src/components/editorMemoryProbeRuntime.ts
Normal file
82
src/components/editorMemoryProbeRuntime.ts
Normal 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())))
|
||||
}
|
||||
61
src/components/editorMemoryProbeTypes.ts
Normal file
61
src/components/editorMemoryProbeTypes.ts
Normal 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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
17
src/components/folder-tree/folderTreeLayout.ts
Normal file
17
src/components/folder-tree/folderTreeLayout.ts
Normal 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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user