Compare commits

...

39 Commits

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 15:02:18 +02:00
lucaronin
8272e68934 fix: ignore stale rich editor beforeinput views 2026-05-01 13:33:39 +02:00
lucaronin
7ba20c1e83 fix: guard stale table handle drags 2026-05-01 12:55:23 +02:00
lucaronin
3384b9acd0 fix: make editor left block handle draggable 2026-05-01 12:15:14 +02:00
lucaronin
8b36ff6b00 fix: avoid active editor remount on unrelated vault refresh 2026-05-01 11:40:14 +02:00
lucaronin
acb35cb5df docs: add/update ADR for Tauri frontend readiness watchdog (guard — from commit 1c010dd) 2026-05-01 08:03:02 +02:00
lucaronin
a8bda0a11b fix: allow linux window controls 2026-05-01 07:01:58 +02:00
lucaronin
4acbe17e1e refactor: consolidate sidebar item interactions 2026-05-01 06:34:25 +02:00
lucaronin
09759d204c test: cover traditional chinese wikilinks 2026-05-01 06:11:09 +02:00
lucaronin
7a01ef6952 fix: recover formula note parse failures 2026-05-01 05:44:22 +02:00
171 changed files with 10064 additions and 3103 deletions

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

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

View File

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

View File

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

View File

@@ -166,9 +166,9 @@ Asset previewability is inferred in the renderer from the filename extension (`s
### Note Content Freshness
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. Before showing cached markdown or editor-ready blocks, `useTabManagement` validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
The renderer may cache recently opened or preloaded markdown content, but cached content is only a performance hint. `useTabManagement` can reuse cached text immediately when it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`; otherwise it validates the cached string with the `validate_note_content` Tauri command. That command re-enters the same vault path boundary checks as `get_note_content` and compares the cached text against the current on-disk file bytes. A mismatch, missing file, or unreadable file falls back to the normal fresh-read path and existing missing/unreadable recovery.
Prepared BlockNote blocks in `useEditorTabSwap` are keyed by path plus source content. They can be built ahead of time from prefetched markdown, but they are reused only when the validated raw content for that path is identical to the source content that produced the blocks.
`useEditorTabSwap` may reuse BlockNote blocks that were already opened successfully or warmed from prefetched raw content, keyed by vault, path, and exact source content. Background warming is limited to likely next large Markdown notes and defers while the editor is unmounted, raw mode is active, or recent typing/navigation is still inside the foreground idle window. Every async editor swap carries a generation and source-content token so stale conversion results cannot overwrite newer file content or dirty editor state.
### Entity Types (isA / type)
@@ -208,7 +208,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
| Property | Type | Description |
|----------|------|-------------|
| `icon` | string | Type icon as a Phosphor name (kebab-case, e.g., "cooking-pot") |
| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
| `color` | string | Accent palette key (`red`, `purple`, `blue`, `green`, `yellow`, `orange`, `teal`, `pink`, `gray`) or a valid CSS color value such as `cyan`, `#22d3ee`, or `rgb(34, 211, 238)` |
| `order` | number | Sidebar display order (lower = higher priority) |
| `sidebar_label` | string | Custom label overriding auto-pluralization |
| `template` | string | Markdown template for new notes of this type |
@@ -309,6 +309,7 @@ type SidebarSelection =
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `src/components/sidebar/sidebarHooks.ts` owns the shared sidebar interaction primitives for menu positioning/dismissal and inline rename input behavior. Folder, Type, and saved View rows keep their domain-specific actions local, but use those primitives so right-click menus and rename fields have the same outside-click, Escape, focus, blur, and submit semantics.
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
@@ -333,6 +334,12 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
- Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood.
## Command Surface
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups.
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
## File System Integration
### Vault Scanning (Rust)
@@ -356,7 +363,7 @@ 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`.
@@ -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,7 +562,8 @@ 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.
@@ -625,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

View File

@@ -88,7 +88,7 @@ flowchart LR
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape. Large folder filtering runs on the blocking Tokio pool and drains `git check-ignore` output while feeding stdin so broad ignore matches cannot freeze the native UI thread.
#### External Change Detection
@@ -102,9 +102,9 @@ Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.m
#### Note Opening Fast Path
Note opening uses a bounded in-memory fast path split across raw content and editor-ready blocks. `useTabManagement` owns the raw markdown prefetch cache and `useEditorTabSwap` owns the prepared BlockNote block cache. Cached or preloaded markdown is never rendered directly: before reusing it, the renderer calls the `validate_note_content` Tauri command, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
Note opening uses bounded in-memory fast paths for raw content and parsed editor blocks. `useTabManagement` owns the markdown/text prefetch cache and treats every cached value as a performance hint only: identity-matched entries (`modifiedAt` + `fileSize`) can be reused immediately, while identity-missing or identity-mismatched cached text is checked with `validate_note_content`, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
The note list opportunistically preloads visible and adjacent markdown/text entries after a short idle delay. Once raw content resolves, the editor prepares BlockNote blocks in the background when it is mounted and not in raw mode. This targets the expensive markdown-to-editor conversion stage while keeping filesystem content authoritative and keeping preload memory bounded by the same cache limits as ordinary note switches.
The note list opportunistically preloads visible and adjacent markdown/text entries after a short delay. When a large warmed Markdown note resolves, `useEditorTabSwap` may parse it into a bounded parsed-block cache only after foreground editor work has been idle and the rich editor is mounted. Parsed blocks are keyed by vault, path, and exact source content; every async swap carries a generation/source-content token so stale conversion results cannot overwrite newer file content or dirty editor state. The editor never renders a preview surface that later morphs into BlockNote. See [ADR-0105](./adr/0105-editor-correctness-and-responsiveness-contract.md).
## Tech Stack
@@ -217,7 +217,7 @@ The main Tauri window also persists its last normal size and screen position in
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
@@ -644,7 +644,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 |
@@ -684,6 +684,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 |
@@ -822,7 +823,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`.
@@ -847,13 +848,14 @@ Selection-dependent actions are wired through the command palette and the native
Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `src/shared/appCommandManifest.json` is the shared command/menu metadata source for command IDs, menu labels, accelerators, native-menu enablement groups, and deterministic QA flags
- `appCommandCatalog.ts` derives renderer command IDs, shortcut lookup maps, Linux menu sections, and QA metadata from that manifest
- `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`

View File

@@ -37,13 +37,13 @@ On some Wayland systems, the Linux AppImage may fail to launch with:
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
```
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with the system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
```bash
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib/libwayland-client.so ./Tolaria*.AppImage
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib64/libwayland-client.so.0 ./Tolaria*.AppImage
```
If your distribution stores the library elsewhere, use that path instead, for example `/usr/lib64/libwayland-client.so.0` or `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`.
If your distribution stores the 64-bit library elsewhere, use that path instead, for example `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`. On 64-bit Fedora, avoid `/usr/lib/libwayland-client.so.0`; that path can point at a 32-bit library and be ignored by the loader with a wrong ELF class warning.
## Quick Start

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -385,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
@@ -525,3 +527,4 @@ files:
locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
locale.vi: 7b80fae85640c16cdb0261bef0c27636
locale.plPL: c730389bc8d99e59c867766babdd48b5

View File

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

View File

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

View File

@@ -1,6 +1,343 @@
diff --git a/dist/TrailingNode-CxM966vN.js b/dist/TrailingNode-CxM966vN.js
index 3ac6fe16aa9ae605a59916f8343b8440befda89e..52df094addfedadcf5f7cee412fa637ddcd2604b 100644
--- a/dist/TrailingNode-CxM966vN.js
+++ b/dist/TrailingNode-CxM966vN.js
@@ -1183,6 +1183,10 @@ class Ft {
b(this, "updateState", (e) => {
this.state = e, this.emitUpdate(this.state);
});
+ b(this, "hideMenu", () => {
+ var e;
+ (e = this.state) != null && e.show && (this.state.show = !1, this.updateState(this.state));
+ });
b(this, "updateStateFromMousePos", () => {
var o, r, s, i, a;
if (this.menuFrozen || !this.mousePos)
@@ -1192,33 +1196,39 @@ class Ft {
clientY: this.mousePos.y
});
if ((e == null ? void 0 : e.element) !== this.pmView.dom || e.distance > se) {
- (o = this.state) != null && o.show && (this.state.show = !1, this.updateState(this.state));
+ this.hideMenu();
return;
}
const t = Ht(this.mousePos, this.pmView);
if (!t || !this.editor.isEditable) {
- (r = this.state) != null && r.show && (this.state.show = !1, this.updateState(this.state));
+ this.hideMenu();
+ return;
+ }
+ if ((s = this.state) != null && s.show && ((i = this.hoveredBlock) != null && i.hasAttribute("data-id")) && ((a = this.hoveredBlock) == null ? void 0 : a.getAttribute("data-id")) === t.id)
+ return;
+ this.hoveredBlock = t.node;
+ const c = this.hoveredBlock.getAttribute("data-id"), l = c ? this.editor.getBlock(c) : void 0;
+ if (!l) {
+ this.hideMenu();
return;
}
- if (!((s = this.state) != null && s.show && ((i = this.hoveredBlock) != null && i.hasAttribute("data-id")) && ((a = this.hoveredBlock) == null ? void 0 : a.getAttribute("data-id")) === t.id) && (this.hoveredBlock = t.node, this.editor.isEditable)) {
- const c = t.node.getBoundingClientRect(), l = t.node.closest("[data-node-type=column]");
+ if (this.editor.isEditable) {
+ const u = t.node.getBoundingClientRect(), h = t.node.closest("[data-node-type=column]");
this.state = {
show: !0,
referencePos: new DOMRect(
- l ? (
+ h ? (
// We take the first child as column elements have some default
// padding. This is a little weird since this child element will
// be the first block, but since it's always non-nested and we
// only take the x coordinate, it's ok.
- l.firstElementChild.getBoundingClientRect().x
+ h.firstElementChild.getBoundingClientRect().x
) : this.pmView.dom.firstChild.getBoundingClientRect().x,
- c.y,
- c.width,
- c.height
+ u.y,
+ u.width,
+ u.height
),
- block: this.editor.getBlock(
- this.hoveredBlock.getAttribute("data-id")
- )
+ block: l
}, this.updateState(this.state);
}
});
@@ -1522,6 +1532,30 @@ function Ut(n) {
function M(n) {
return Array.prototype.indexOf.call(n.parentElement.childNodes, n);
}
+function isValidTablePosition(n) {
+ return Number.isInteger(n) && n >= 0;
+}
+function isValidCellIndex(n) {
+ return Number.isInteger(n) && n >= 0;
+}
+function isValidRelativeCell(n) {
+ return isValidCellIndex(n.row) && isValidCellIndex(n.col);
+}
+function resolveCellPosition(n, e, t) {
+ if (!isValidTablePosition(e) || !isValidRelativeCell(t))
+ return;
+ try {
+ const o = n.doc.resolve(e + 1);
+ if (t.row >= o.node().childCount)
+ return;
+ const r = n.doc.resolve(
+ o.posAtIndex(t.row) + 1
+ );
+ return t.col >= r.node().childCount ? void 0 : n.doc.resolve(r.posAtIndex(t.col));
+ } catch {
+ return;
+ }
+}
function _t(n) {
let e = n;
for (; e && e.nodeName !== "TD" && e.nodeName !== "TH" && !e.classList.contains("tableWrapper"); ) {
@@ -1557,6 +1591,12 @@ class Kt {
b(this, "menuFrozen", !1);
b(this, "mouseState", "up");
b(this, "prevWasEditable", null);
+ b(this, "hideHandles", (e = {}) => {
+ const { resetCell: t = !1, resetDragging: o = !1 } = e;
+ if (!this.state || !this.state.show && !t && !o)
+ return;
+ this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, o && (this.state.draggingState = void 0), t && (this.state.rowIndex = void 0, this.state.colIndex = void 0, this.state.referencePosCell = void 0), this.emitUpdate();
+ });
b(this, "viewMousedownHandler", () => {
this.mouseState = "down";
});
@@ -1569,11 +1609,11 @@ class Kt {
return;
const t = _t(e.target);
if ((t == null ? void 0 : t.type) === "cell" && this.mouseState === "down" && !((l = this.state) != null && l.draggingState)) {
- this.mouseState = "selecting", (u = this.state) != null && u.show && (this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.emitUpdate());
+ this.mouseState = "selecting", this.hideHandles();
return;
}
if (!t || !this.editor.isEditable) {
- (h = this.state) != null && h.show && (this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.emitUpdate());
+ this.hideHandles();
return;
}
if (!t.tbodyNode)
@@ -1671,9 +1711,7 @@ class Kt {
if (this.mouseState = "up", this.state === void 0 || this.state.draggingState === void 0)
return !1;
if (this.state.rowIndex === void 0 || this.state.colIndex === void 0)
- throw new Error(
- "Attempted to drop table row or column, but no table block was hovered prior."
- );
+ return e.preventDefault(), this.hideHandles({ resetCell: !0, resetDragging: !0 }), !1;
e.preventDefault();
const { draggingState: t, colIndex: o, rowIndex: r } = this.state, s = this.state.block.content.columnWidths;
if (t.draggedCellOrientation === "row") {
@@ -1738,7 +1776,7 @@ class Kt {
if (this.state.block = this.editor.getBlock(this.state.block.id), !this.state.block || this.state.block.type !== "table" || // when collaborating, the table element might be replaced and out of date
// because yjs replaces the element when for example you change the color via the side menu
!((r = this.tableElement) != null && r.isConnected)) {
- this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.emitUpdate();
+ this.hideHandles();
return;
}
const { height: e, width: t } = Ve(
@@ -1746,10 +1784,10 @@ class Kt {
);
this.state.rowIndex !== void 0 && this.state.colIndex !== void 0 && (this.state.rowIndex >= e && (this.state.rowIndex = e - 1), this.state.colIndex >= t && (this.state.colIndex = t - 1));
const o = this.tableElement.querySelector("tbody");
- if (!o)
- throw new Error(
- "Table block does not contain a 'tbody' HTML element. This should never happen."
- );
+ if (!o) {
+ this.hideHandles({ resetCell: !0 });
+ return;
+ }
if (this.state.rowIndex !== void 0 && this.state.colIndex !== void 0) {
const i = o.children[this.state.rowIndex].children[this.state.colIndex];
i ? this.state.referencePosCell = i.getBoundingClientRect() : (this.state.rowIndex = void 0, this.state.colIndex = void 0);
@@ -1838,10 +1876,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* is used as the column drag handle.
*/
colDragStart(o) {
- if (e === void 0 || e.state === void 0 || e.state.colIndex === void 0)
- throw new Error(
- "Attempted to drag table column, but no table block was hovered prior."
- );
+ if (e === void 0 || e.state === void 0 || e.state.colIndex === void 0 || e.tablePos === void 0) {
+ e == null || e.hideHandles({ resetCell: !0, resetDragging: !0 });
+ return;
+ }
e.state.draggingState = {
draggedCellOrientation: "col",
originalIndex: e.state.colIndex,
@@ -1860,10 +1898,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* is used as the row drag handle.
*/
rowDragStart(o) {
- if (e.state === void 0 || e.state.rowIndex === void 0)
- throw new Error(
- "Attempted to drag table row, but no table block was hovered prior."
- );
+ if (e === void 0 || e.state === void 0 || e.state.rowIndex === void 0 || e.tablePos === void 0) {
+ e == null || e.hideHandles({ resetCell: !0, resetDragging: !0 });
+ return;
+ }
e.state.draggingState = {
draggedCellOrientation: "row",
originalIndex: e.state.rowIndex,
@@ -1882,10 +1920,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* used as the row drag handle, and the one used as the column drag handle.
*/
dragEnd() {
- if (e.state === void 0)
- throw new Error(
- "Attempted to drag table row, but no table block was hovered prior."
- );
+ if (e === void 0 || e.state === void 0) {
+ n.headless || Ut(n.prosemirrorView.root);
+ return;
+ }
e.state.draggingState = void 0, e.emitUpdate(), n.transact((o) => o.setMeta(D, null)), !n.headless && Ut(n.prosemirrorView.root);
},
/**
@@ -1918,30 +1956,37 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
setCellSelection(o, r, s = r) {
if (!e)
throw new Error("Table handles view not initialized");
- const i = o.doc.resolve(e.tablePos + 1), a = o.doc.resolve(
- i.posAtIndex(r.row) + 1
- ), c = o.doc.resolve(
- // No need for +1, since CellSelection expects the position before the cell
- a.posAtIndex(r.col)
- ), l = o.doc.resolve(
- i.posAtIndex(s.row) + 1
- ), u = o.doc.resolve(
- // No need for +1, since CellSelection expects the position before the cell
- l.posAtIndex(s.col)
- ), h = o.tr;
- return h.setSelection(
- new yt(c, u)
- ), o.apply(h);
+ const i = resolveCellPosition(
+ o,
+ e.tablePos,
+ r
+ ), a = resolveCellPosition(
+ o,
+ e.tablePos,
+ s
+ );
+ if (!i || !a)
+ return;
+ const c = o.tr;
+ return c.setSelection(
+ new yt(i, a)
+ ), o.apply(c);
},
/**
* Adds a row or column to the table using prosemirror-table commands
*/
addRowOrColumn(o, r) {
+ if (!e || !isValidTablePosition(e.tablePos) || !isValidCellIndex(o)) {
+ e == null || e.hideHandles({ resetCell: !0 });
+ return;
+ }
n.exec((s, i) => {
const a = this.setCellSelection(
s,
r.orientation === "row" ? { row: o, col: 0 } : { row: 0, col: o }
);
+ if (!a)
+ return e == null || e.hideHandles({ resetCell: !0 }), !1;
return r.orientation === "row" ? r.side === "above" ? pt(a, i) : ft(a, i) : r.side === "left" ? gt(a, i) : wt(a, i);
});
},
@@ -1949,17 +1994,23 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* Removes a row or column from the table using prosemirror-table commands
*/
removeRowOrColumn(o, r) {
+ if (!isValidCellIndex(o))
+ return !1;
return r === "row" ? n.exec((s, i) => {
const a = this.setCellSelection(s, {
row: o,
col: 0
});
+ if (!a)
+ return !1;
return ht(a, i);
}) : n.exec((s, i) => {
const a = this.setCellSelection(s, {
row: 0,
col: o
});
+ if (!a)
+ return !1;
return mt(a, i);
});
},
@@ -1973,6 +2024,8 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
o.relativeStartCell,
o.relativeEndCell
) : r;
+ if (!i)
+ return !1;
return ut(i, s);
});
},
@@ -1983,6 +2036,8 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
splitCell(o) {
return n.exec((r, s) => {
const i = o ? this.setCellSelection(r, o) : r;
+ if (!i)
+ return !1;
return dt(i, s);
});
},
diff --git a/dist/blocknote.js b/dist/blocknote.js
index 0f97dc48ddedfb9661b81c7469ce504dc974e284..66600e91f871ed86e4942b794d2bce8f96882ae0 100644
--- a/dist/blocknote.js
+++ b/dist/blocknote.js
@@ -2037,7 +2037,9 @@ const de = () => {
]
})
);
-let fe = !1;
+const bnLinkifyProtocolFlag = "__tolariaBlockNoteLinkifyProtocolsRegistered";
+const bnGlobalObject = typeof globalThis > "u" ? void 0 : globalThis;
+let fe = Boolean(bnGlobalObject && bnGlobalObject[bnLinkifyProtocolFlag]);
function mn(o, e) {
const t = [
I.ClipboardTextSerializer,
@@ -2062,7 +2064,10 @@ function mn(o, e) {
}).configure({
defaultProtocol: Ct,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
- protocols: fe ? [] : Bt
+ // Tolaria routes editor link clicks through its guarded native opener.
+ openOnClick: !1,
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
+ protocols: []
}),
...Object.values(o.schema.styleSpecs).map((n) => n.implementation.mark.configure({
editor: o
@@ -2112,7 +2117,7 @@ function mn(o, e) {
),
Do(o)
];
- return fe = !0, t;
+ return fe = !0, bnGlobalObject && (bnGlobalObject[bnLinkifyProtocolFlag] = !0), t;
}
function kn(o, e) {
const t = [
diff --git a/dist/defaultBlocks-DE5GNdJH.js b/dist/defaultBlocks-DE5GNdJH.js
index b2761001278486a8b2ac1d10c82420b4994e96d9..c44e9c09100c6d1803bd71b2a338c43e34b74886 100644
--- a/dist/defaultBlocks-DE5GNdJH.js
+++ b/dist/defaultBlocks-DE5GNdJH.js
@@ -1120,6 +1120,8 @@
@@ -1120,6 +1120,8 @@ const Ut = ({ defaultLanguage: e = "text" }) => ({
), i.value = t.props.language || e.defaultLanguage || "text", n.isEditable) {
const l = (u) => {
const d = u.target.value;
@@ -9,7 +346,7 @@
n.updateBlock(t.id, { props: { language: d } });
};
i.addEventListener("change", l), s = () => i.removeEventListener("change", l);
@@ -2621,6 +2623,9 @@
@@ -2621,6 +2623,9 @@ class On {
this.state.referencePos = o.getBoundingClientRect().toJSON(), this.emitUpdate(this.pluginState.triggerCharacter);
}
});
@@ -19,7 +356,7 @@
M(this, "closeMenu", () => {
this.editor.transact((t) => t.setMeta(B, null));
});
@@ -2634,7 +2639,7 @@
@@ -2634,7 +2639,7 @@ class On {
this.editor = t, this.pluginState = void 0, this.emitUpdate = (a) => {
var s;
if (!this.state)
@@ -28,7 +365,7 @@
n(a, {
...this.state,
ignoreQueryLength: (s = this.pluginState) == null ? void 0 : s.ignoreQueryLength
@@ -2649,17 +2654,21 @@
@@ -2649,17 +2654,21 @@ class On {
if (!a && !(o !== void 0 && r !== void 0) && !s)
return;
if (this.pluginState = s ? o : r, s || !this.editor.isEditable) {
@@ -53,7 +390,7 @@
}
destroy() {
var t;
@@ -2761,7 +2770,7 @@
@@ -2761,7 +2770,7 @@ const B = new Ze("SuggestionMenuPlugin"), _n = k(({ editor: e }) => {
if (a === s) {
const c = r.state.doc;
for (const l of t) {
@@ -62,22 +399,53 @@
if (l === u)
return r.dispatch(r.state.tr.insertText(i)), r.dispatch(
r.state.tr.setMeta(B, {
diff --git a/node_modules/.bin/uuid b/node_modules/.bin/uuid
new file mode 100755
index 0000000000000000000000000000000000000000..2bc12aee9f172fb55715572a6597f2e4b2e5b4ec
--- /dev/null
+++ b/node_modules/.bin/uuid
@@ -0,0 +1,21 @@
+#!/bin/sh
+basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
+
+case `uname` in
+ *CYGWIN*|*MINGW*|*MSYS*)
+ if command -v cygpath > /dev/null 2>&1; then
+ basedir=`cygpath -w "$basedir"`
+ fi
+ ;;
+esac
+
+if [ -z "$NODE_PATH" ]; then
+ export NODE_PATH="/Volumes/Jupiter/Workspace/laputa-app/node_modules/.pnpm/uuid@8.3.2/node_modules/uuid/node_modules:/Volumes/Jupiter/Workspace/laputa-app/node_modules/.pnpm/uuid@8.3.2/node_modules:/Volumes/Jupiter/Workspace/laputa-app/node_modules/.pnpm/node_modules"
+else
+ export NODE_PATH="/Volumes/Jupiter/Workspace/laputa-app/node_modules/.pnpm/uuid@8.3.2/node_modules/uuid/node_modules:/Volumes/Jupiter/Workspace/laputa-app/node_modules/.pnpm/uuid@8.3.2/node_modules:/Volumes/Jupiter/Workspace/laputa-app/node_modules/.pnpm/node_modules:$NODE_PATH"
+fi
+if [ -x "$basedir/node" ]; then
+ exec "$basedir/node" "$basedir/../../../../uuid/dist/bin/uuid" "$@"
+else
+ exec node "$basedir/../../../../uuid/dist/bin/uuid" "$@"
+fi
diff --git a/src/blocks/Code/block.ts b/src/blocks/Code/block.ts
index dbb7fc33a9add7a96488349876bc56ad60111a3f..58c3cf181f25467d85cd8c3788b5b73dec88175a 100644
--- a/src/blocks/Code/block.ts
+++ b/src/blocks/Code/block.ts
@@ -133,6 +133,10 @@
if (editor.isEditable) {
@@ -134,6 +134,10 @@ export const createCodeBlockSpec = createBlockSpec(
const handleLanguageChange = (event: Event) => {
const language = (event.target as HTMLSelectElement).value;
+
+ if (!editor.getBlock(block.id)) {
+ return;
+ }
+
editor.updateBlock(block.id, { props: { language } });
};
select.addEventListener("change", handleLanguageChange);
diff --git a/src/editor/managers/ExtensionManager/extensions.ts b/src/editor/managers/ExtensionManager/extensions.ts
index 45f0acf8e5d869eb13a3c3d70e6f67059f86c598..2a4d15c8e8465181cc009ed9912cd284e0930251 100644
--- a/src/editor/managers/ExtensionManager/extensions.ts
+++ b/src/editor/managers/ExtensionManager/extensions.ts
@@ -84,7 +84,10 @@
@@ -84,7 +84,10 @@ export function getDefaultTiptapExtensions(
}).configure({
defaultProtocol: DEFAULT_LINK_PROTOCOL,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
@@ -89,43 +457,11 @@
}),
...(Object.values(editor.schema.styleSpecs).map((styleSpec) => {
return styleSpec.implementation.mark.configure({
--- a/dist/blocknote.js
+++ b/dist/blocknote.js
@@ -2037,7 +2037,9 @@
]
})
);
-let fe = !1;
+const bnLinkifyProtocolFlag = "__tolariaBlockNoteLinkifyProtocolsRegistered";
+const bnGlobalObject = typeof globalThis > "u" ? void 0 : globalThis;
+let fe = Boolean(bnGlobalObject && bnGlobalObject[bnLinkifyProtocolFlag]);
function mn(o, e) {
const t = [
I.ClipboardTextSerializer,
@@ -2062,7 +2064,10 @@
}).configure({
defaultProtocol: Ct,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
- protocols: fe ? [] : Bt
+ // Tolaria routes editor link clicks through its guarded native opener.
+ openOnClick: !1,
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
+ protocols: []
}),
...Object.values(o.schema.styleSpecs).map((n) => n.implementation.mark.configure({
editor: o
@@ -2112,7 +2117,7 @@
),
Do(o)
];
- return fe = !0, t;
+ return fe = !0, bnGlobalObject && (bnGlobalObject[bnLinkifyProtocolFlag] = !0), t;
}
function kn(o, e) {
const t = [
diff --git a/src/extensions/SideMenu/SideMenu.ts b/src/extensions/SideMenu/SideMenu.ts
index 769e4a17154db2d88472696638db96e20131dfdb..a6c7f1a76eaa4db7b58774ae71ef47ab62805f66 100644
--- a/src/extensions/SideMenu/SideMenu.ts
+++ b/src/extensions/SideMenu/SideMenu.ts
@@ -194,6 +194,15 @@
@@ -194,6 +194,15 @@ export class SideMenuView<
this.emitUpdate(this.state);
};
@@ -141,7 +477,7 @@
updateStateFromMousePos = () => {
if (this.menuFrozen || !this.mousePos) {
return;
@@ -208,10 +217,7 @@
@@ -208,10 +217,7 @@ export class SideMenuView<
closestEditor?.element !== this.pmView.dom ||
closestEditor.distance > DISTANCE_TO_CONSIDER_EDITOR_BOUNDS
) {
@@ -153,7 +489,7 @@
return;
}
@@ -219,11 +225,7 @@
@@ -219,11 +225,7 @@ export class SideMenuView<
// Closes the menu if the mouse cursor is beyond the editor vertically.
if (!block || !this.editor.isEditable) {
@@ -166,7 +502,7 @@
return;
}
@@ -237,6 +239,15 @@
@@ -237,6 +239,15 @@ export class SideMenuView<
}
this.hoveredBlock = block.node;
@@ -182,7 +518,7 @@
// Shows or updates elements.
if (this.editor.isEditable) {
@@ -258,9 +269,7 @@
@@ -258,9 +269,7 @@ export class SideMenuView<
blockContentBoundingBox.width,
blockContentBoundingBox.height,
),
@@ -193,9 +529,11 @@
};
this.updateState(this.state);
}
diff --git a/src/extensions/SuggestionMenu/SuggestionMenu.ts b/src/extensions/SuggestionMenu/SuggestionMenu.ts
index 029103600a98bf8ccd3054a5c43ffa2fd9115c06..d1678ed4761baca0b3495dafc8d751525d528b4e 100644
--- a/src/extensions/SuggestionMenu/SuggestionMenu.ts
+++ b/src/extensions/SuggestionMenu/SuggestionMenu.ts
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@ class SuggestionMenuView {
this.emitUpdate = (menuName: string) => {
if (!this.state) {
@@ -204,7 +542,7 @@
}
emitUpdate(menuName, {
@@ -64,6 +64,15 @@
@@ -64,6 +64,15 @@ class SuggestionMenuView {
}
};
@@ -220,7 +558,7 @@
update(view: EditorView, prevState: EditorState) {
const prev: SuggestionPluginState =
suggestionMenuPluginKey.getState(prevState);
@@ -84,11 +93,7 @@
@@ -84,11 +93,7 @@ class SuggestionMenuView {
this.pluginState = stopped ? prev : next;
if (stopped || !this.editor.isEditable) {
@@ -233,7 +571,7 @@
return;
}
@@ -96,17 +101,20 @@
@@ -96,17 +101,20 @@ class SuggestionMenuView {
`[data-decoration-id="${this.pluginState!.decorationId}"]`,
);
@@ -264,20 +602,79 @@
}
destroy() {
diff --git a/src/extensions/TableHandles/TableHandles.ts b/src/extensions/TableHandles/TableHandles.ts
index 30637742d517bcf136ca562a09a1544d7b03a20d..d8df74b7803efacd42e8cf3a9170e7ec37580f88 100644
--- a/src/extensions/TableHandles/TableHandles.ts
+++ b/src/extensions/TableHandles/TableHandles.ts
@@ -187,6 +187,22 @@
@@ -99,6 +99,46 @@ function getChildIndex(node: Element) {
return Array.prototype.indexOf.call(node.parentElement!.childNodes, node);
}
+function isValidTablePosition(tablePos: number | undefined): tablePos is number {
+ return Number.isInteger(tablePos) && tablePos >= 0;
+}
+
+function isValidCellIndex(index: number | undefined): index is number {
+ return Number.isInteger(index) && index >= 0;
+}
+
+function isValidRelativeCell(cell: RelativeCellIndices) {
+ return isValidCellIndex(cell.row) && isValidCellIndex(cell.col);
+}
+
+function resolveCellPosition(
+ state: EditorState,
+ tablePos: number | undefined,
+ relativeCell: RelativeCellIndices,
+) {
+ if (!isValidTablePosition(tablePos) || !isValidRelativeCell(relativeCell)) {
+ return undefined;
+ }
+
+ try {
+ const tableResolvedPos = state.doc.resolve(tablePos + 1);
+ if (relativeCell.row >= tableResolvedPos.node().childCount) {
+ return undefined;
+ }
+
+ const rowResolvedPos = state.doc.resolve(
+ tableResolvedPos.posAtIndex(relativeCell.row) + 1,
+ );
+ if (relativeCell.col >= rowResolvedPos.node().childCount) {
+ return undefined;
+ }
+
+ return state.doc.resolve(rowResolvedPos.posAtIndex(relativeCell.col));
+ } catch {
+ return undefined;
+ }
+}
+
// Finds the DOM element corresponding to the table cell that the target element
// is currently in. If the target element is not in a table cell, returns null.
function domCellAround(target: Element) {
@@ -187,6 +227,32 @@ export class TableHandlesView implements PluginView {
);
}
+ private hideHandles = ({ resetCell = false } = {}) => {
+ if (!this.state?.show) {
+ hideHandles = ({
+ resetCell = false,
+ resetDragging = false,
+ } = {}) => {
+ if (!this.state) {
+ return;
+ }
+
+ if (!this.state.show && !resetCell && !resetDragging) {
+ return;
+ }
+
+ this.state.show = false;
+ this.state.showAddOrRemoveRowsButton = false;
+ this.state.showAddOrRemoveColumnsButton = false;
+ if (resetDragging) {
+ this.state.draggingState = undefined;
+ }
+ if (resetCell) {
+ this.state.rowIndex = undefined;
+ this.state.colIndex = undefined;
@@ -289,7 +686,7 @@
viewMousedownHandler = () => {
this.mouseState = "down";
};
@@ -222,22 +238,12 @@
@@ -222,22 +288,12 @@ export class TableHandlesView implements PluginView {
// hide draghandles when selecting text as they could be in the way of the user
this.mouseState = "selecting";
@@ -314,7 +711,20 @@
return;
}
@@ -541,11 +547,7 @@
@@ -458,9 +514,9 @@ export class TableHandlesView implements PluginView {
this.state.rowIndex === undefined ||
this.state.colIndex === undefined
) {
- throw new Error(
- "Attempted to drop table row or column, but no table block was hovered prior.",
- );
+ event.preventDefault();
+ this.hideHandles({ resetCell: true, resetDragging: true });
+ return false;
}
event.preventDefault();
@@ -541,11 +597,7 @@ export class TableHandlesView implements PluginView {
// because yjs replaces the element when for example you change the color via the side menu
!this.tableElement?.isConnected
) {
@@ -327,7 +737,7 @@
return;
}
@@ -572,9 +574,8 @@
@@ -572,9 +624,8 @@ export class TableHandlesView implements PluginView {
const tableBody = this.tableElement!.querySelector("tbody");
if (!tableBody) {
@@ -339,120 +749,198 @@
}
if (
--- a/dist/TrailingNode-CxM966vN.js
+++ b/dist/TrailingNode-CxM966vN.js
@@ -1182,6 +1182,10 @@
b(this, "isDragOrigin", !1);
b(this, "updateState", (e) => {
this.state = e, this.emitUpdate(this.state);
+ });
+ b(this, "hideMenu", () => {
+ var e;
+ (e = this.state) != null && e.show && (this.state.show = !1, this.updateState(this.state));
});
b(this, "updateStateFromMousePos", () => {
var o, r, s, i, a;
@@ -1192,33 +1196,39 @@
clientY: this.mousePos.y
});
if ((e == null ? void 0 : e.element) !== this.pmView.dom || e.distance > se) {
- (o = this.state) != null && o.show && (this.state.show = !1, this.updateState(this.state));
+ this.hideMenu();
return;
}
const t = Ht(this.mousePos, this.pmView);
if (!t || !this.editor.isEditable) {
- (r = this.state) != null && r.show && (this.state.show = !1, this.updateState(this.state));
+ this.hideMenu();
return;
}
- if (!((s = this.state) != null && s.show && ((i = this.hoveredBlock) != null && i.hasAttribute("data-id")) && ((a = this.hoveredBlock) == null ? void 0 : a.getAttribute("data-id")) === t.id) && (this.hoveredBlock = t.node, this.editor.isEditable)) {
- const c = t.node.getBoundingClientRect(), l = t.node.closest("[data-node-type=column]");
+ if ((s = this.state) != null && s.show && ((i = this.hoveredBlock) != null && i.hasAttribute("data-id")) && ((a = this.hoveredBlock) == null ? void 0 : a.getAttribute("data-id")) === t.id)
@@ -796,11 +847,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
if (
view === undefined ||
view.state === undefined ||
- view.state.colIndex === undefined
+ view.state.colIndex === undefined ||
+ view.tablePos === undefined
) {
- throw new Error(
- "Attempted to drag table column, but no table block was hovered prior.",
- );
+ view?.hideHandles({ resetCell: true, resetDragging: true });
+ return;
+ this.hoveredBlock = t.node;
+ const c = this.hoveredBlock.getAttribute("data-id"), l = c ? this.editor.getBlock(c) : void 0;
+ if (!l) {
+ this.hideMenu();
}
view.state.draggingState = {
@@ -837,26 +888,30 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
dataTransfer: DataTransfer | null;
clientY: number;
}) {
- if (view!.state === undefined || view!.state.rowIndex === undefined) {
- throw new Error(
- "Attempted to drag table row, but no table block was hovered prior.",
- );
+ if (
+ view === undefined ||
+ view.state === undefined ||
+ view.state.rowIndex === undefined ||
+ view.tablePos === undefined
+ ) {
+ view?.hideHandles({ resetCell: true, resetDragging: true });
+ return;
}
- view!.state.draggingState = {
+ view.state.draggingState = {
draggedCellOrientation: "row",
- originalIndex: view!.state.rowIndex,
+ originalIndex: view.state.rowIndex,
mousePos: event.clientY,
};
- view!.emitUpdate();
+ view.emitUpdate();
editor.transact((tr) =>
tr.setMeta(tableHandlesPluginKey, {
draggedCellOrientation:
- view!.state!.draggingState!.draggedCellOrientation,
- originalIndex: view!.state!.rowIndex,
- newIndex: view!.state!.rowIndex,
- tablePos: view!.tablePos,
+ view.state!.draggingState!.draggedCellOrientation,
+ originalIndex: view.state!.rowIndex,
+ newIndex: view.state!.rowIndex,
+ tablePos: view.tablePos,
}),
);
@@ -874,14 +929,15 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
* used as the row drag handle, and the one used as the column drag handle.
*/
dragEnd() {
- if (view!.state === undefined) {
- throw new Error(
- "Attempted to drag table row, but no table block was hovered prior.",
- );
+ if (view === undefined || view.state === undefined) {
+ if (!editor.headless) {
+ unsetHiddenDragImage(editor.prosemirrorView.root);
+ }
+ return;
}
- view!.state.draggingState = undefined;
- view!.emitUpdate();
+ view.state.draggingState = undefined;
+ view.emitUpdate();
editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null));
@@ -938,22 +994,21 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
throw new Error("Table handles view not initialized");
}
- const tableResolvedPos = state.doc.resolve(view.tablePos! + 1);
- const startRowResolvedPos = state.doc.resolve(
- tableResolvedPos.posAtIndex(relativeStartCell.row) + 1,
- );
- const startCellResolvedPos = state.doc.resolve(
- // No need for +1, since CellSelection expects the position before the cell
- startRowResolvedPos.posAtIndex(relativeStartCell.col),
- );
- const endRowResolvedPos = state.doc.resolve(
- tableResolvedPos.posAtIndex(relativeEndCell.row) + 1,
+ const startCellResolvedPos = resolveCellPosition(
+ state,
+ view.tablePos,
+ relativeStartCell,
);
- const endCellResolvedPos = state.doc.resolve(
- // No need for +1, since CellSelection expects the position before the cell
- endRowResolvedPos.posAtIndex(relativeEndCell.col),
+ const endCellResolvedPos = resolveCellPosition(
+ state,
+ view.tablePos,
+ relativeEndCell,
);
+ if (!startCellResolvedPos || !endCellResolvedPos) {
+ return undefined;
+ }
+
// Begin a new transaction to set the selection
const tr = state.tr;
@@ -975,6 +1030,15 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
| { orientation: "row"; side: "above" | "below" }
| { orientation: "column"; side: "left" | "right" },
) {
+ if (
+ !view ||
+ !isValidTablePosition(view.tablePos) ||
+ !isValidCellIndex(index)
+ ) {
+ view?.hideHandles({ resetCell: true });
+ return;
+ }
+ if (this.editor.isEditable) {
+ const u = t.node.getBoundingClientRect(), h = t.node.closest("[data-node-type=column]");
this.state = {
show: !0,
referencePos: new DOMRect(
- l ? (
+ h ? (
// We take the first child as column elements have some default
// padding. This is a little weird since this child element will
// be the first block, but since it's always non-nested and we
// only take the x coordinate, it's ok.
- l.firstElementChild.getBoundingClientRect().x
+ h.firstElementChild.getBoundingClientRect().x
) : this.pmView.dom.firstChild.getBoundingClientRect().x,
- c.y,
- c.width,
- c.height
+ u.y,
+ u.width,
+ u.height
),
- block: this.editor.getBlock(
- this.hoveredBlock.getAttribute("data-id")
- )
+ block: l
}, this.updateState(this.state);
+
editor.exec((beforeState, dispatch) => {
const state = this.setCellSelection(
beforeState,
@@ -983,6 +1047,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
: { row: 0, col: index },
);
+ if (!state) {
+ view?.hideHandles({ resetCell: true });
+ return false;
+ }
+
if (direction.orientation === "row") {
if (direction.side === "above") {
return addRowBefore(state, dispatch);
@@ -1006,12 +1075,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
index: RelativeCellIndices["row"] | RelativeCellIndices["col"],
direction: "row" | "column",
) {
+ if (!isValidCellIndex(index)) {
+ return false;
+ }
+
if (direction === "row") {
return editor.exec((beforeState, dispatch) => {
const state = this.setCellSelection(beforeState, {
row: index,
col: 0,
});
+ if (!state) {
+ return false;
+ }
return deleteRow(state, dispatch);
});
} else {
@@ -1020,6 +1096,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
row: 0,
col: index,
});
+ if (!state) {
+ return false;
+ }
return deleteColumn(state, dispatch);
});
}
});
@@ -1557,6 +1567,13 @@
b(this, "menuFrozen", !1);
b(this, "mouseState", "up");
b(this, "prevWasEditable", null);
+ b(this, "hideHandles", (e = {}) => {
+ var t;
+ const { resetCell: o = !1 } = e;
+ if (!((t = this.state) != null && t.show))
+ return;
+ this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, o && (this.state.rowIndex = void 0, this.state.colIndex = void 0, this.state.referencePosCell = void 0), this.emitUpdate();
+ });
b(this, "viewMousedownHandler", () => {
this.mouseState = "down";
});
@@ -1569,11 +1586,11 @@
return;
const t = _t(e.target);
if ((t == null ? void 0 : t.type) === "cell" && this.mouseState === "down" && !((l = this.state) != null && l.draggingState)) {
- this.mouseState = "selecting", (u = this.state) != null && u.show && (this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.emitUpdate());
+ this.mouseState = "selecting", this.hideHandles();
return;
}
if (!t || !this.editor.isEditable) {
- (h = this.state) != null && h.show && (this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.emitUpdate());
+ this.hideHandles();
return;
}
if (!t.tbodyNode)
@@ -1738,7 +1755,7 @@
if (this.state.block = this.editor.getBlock(this.state.block.id), !this.state.block || this.state.block.type !== "table" || // when collaborating, the table element might be replaced and out of date
// because yjs replaces the element when for example you change the color via the side menu
!((r = this.tableElement) != null && r.isConnected)) {
- this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.emitUpdate();
+ this.hideHandles();
return;
}
const { height: e, width: t } = Ve(
@@ -1746,10 +1763,10 @@
);
this.state.rowIndex !== void 0 && this.state.colIndex !== void 0 && (this.state.rowIndex >= e && (this.state.rowIndex = e - 1), this.state.colIndex >= t && (this.state.colIndex = t - 1));
const o = this.tableElement.querySelector("tbody");
- if (!o)
- throw new Error(
- "Table block does not contain a 'tbody' HTML element. This should never happen."
- );
+ if (!o) {
+ this.hideHandles({ resetCell: !0 });
+ return;
+ }
if (this.state.rowIndex !== void 0 && this.state.colIndex !== void 0) {
const i = o.children[this.state.rowIndex].children[this.state.colIndex];
i ? this.state.referencePosCell = i.getBoundingClientRect() : (this.state.rowIndex = void 0, this.state.colIndex = void 0);
@@ -1041,6 +1120,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
)
: beforeState;
+ if (!state) {
+ return false;
+ }
+
return mergeCells(state, dispatch);
});
},
@@ -1055,6 +1138,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
? this.setCellSelection(beforeState, relativeCellToSplit)
: beforeState;
+ if (!state) {
+ return false;
+ }
+
return splitCell(state, dispatch);
});
},

16
pnpm-lock.yaml generated
View File

@@ -6,7 +6,7 @@ settings:
patchedDependencies:
'@blocknote/core@0.46.2':
hash: 2062254e454085b43513178f0d065babc744a6114cce48f49ec464793e03f57b
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=2062254e454085b43513178f0d065babc744a6114cce48f49ec464793e03f57b)(@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=2062254e454085b43513178f0d065babc744a6114cce48f49ec464793e03f57b)(@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=2062254e454085b43513178f0d065babc744a6114cce48f49ec464793e03f57b)(@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=2062254e454085b43513178f0d065babc744a6114cce48f49ec464793e03f57b)(@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=2062254e454085b43513178f0d065babc744a6114cce48f49ec464793e03f57b)(@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=2062254e454085b43513178f0d065babc744a6114cce48f49ec464793e03f57b)(@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=2062254e454085b43513178f0d065babc744a6114cce48f49ec464793e03f57b)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10

View File

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

View File

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

View File

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

View File

@@ -203,6 +203,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)
@@ -561,6 +562,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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {
@@ -581,6 +451,7 @@ macro_rules! app_invoke_handler {
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,
@@ -611,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)]
@@ -633,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;
@@ -658,98 +528,6 @@ mod tests {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
#[test]
fn linux_appimage_startup_env_overrides_are_empty_outside_appimage_launches() {
let overrides = linux_appimage_startup_env_overrides_with(|_| None);
assert!(overrides.is_empty());
}
#[test]
fn linux_appimage_startup_env_overrides_disable_unstable_webkit_rendering_for_appimages() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}
]
);
}
#[test]
fn linux_appimage_startup_env_overrides_preserve_explicit_user_setting_per_variable() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}]
);
}
#[test]
fn linux_appimage_wayland_preload_uses_first_available_system_library() {
let preload_path = linux_appimage_wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
_ => None,
},
|path| path == "/lib/x86_64-linux-gnu/libwayland-client.so.0",
);
assert_eq!(
preload_path,
Some("/lib/x86_64-linux-gnu/libwayland-client.so.0")
);
}
#[test]
fn linux_appimage_wayland_preload_preserves_explicit_ld_preload() {
let preload_path = linux_appimage_wayland_client_preload_path_with(
|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WAYLAND_DISPLAY" => Some("wayland-0".to_string()),
"LD_PRELOAD" => Some("/custom/libwayland-client.so".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
#[test]
fn linux_appimage_wayland_preload_is_empty_for_x11_sessions() {
let preload_path = linux_appimage_wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("x11".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_path_uses_persisted_active_vault() {

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { AppLoadingSkeleton } from './components/AppLoadingSkeleton'
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { FeedbackDialog } from './components/FeedbackDialog'
@@ -1231,6 +1230,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)
@@ -1614,14 +1617,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={() => {
@@ -1660,7 +1660,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">
@@ -1668,7 +1668,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} />
</>
@@ -1690,7 +1690,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}
@@ -1755,7 +1755,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || vault.isLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LinuxMenuButton } from './LinuxMenuButton'
const MENU_TEST_TIMEOUT_MS = 10_000
const { close, invoke, minimize, toggleMaximize } = vi.hoisted(() => ({
invoke: vi.fn().mockResolvedValue(undefined),
minimize: vi.fn().mockResolvedValue(undefined),
@@ -41,7 +43,7 @@ describe('LinuxMenuButton', () => {
expect(screen.getByText('Ctrl+Shift+L')).toBeInTheDocument()
fireEvent.click(await screen.findByText('Toggle AI Panel'))
expect(invoke).toHaveBeenCalledWith('trigger_menu_command', { id: 'view-toggle-ai-chat' })
})
}, MENU_TEST_TIMEOUT_MS)
it('invokes direct window actions from the Window submenu', async () => {
render(<LinuxMenuButton />)

View File

@@ -1,7 +1,6 @@
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import type { AppCommandId } from '../hooks/appCommandCatalog'
import { APP_COMMAND_DEFINITIONS, APP_COMMAND_IDS } from '../hooks/appCommandCatalog'
import { APP_COMMAND_MENU_SECTIONS } from '../hooks/appCommandCatalog'
import { Button } from './ui/button'
import {
DropdownMenu,
@@ -17,7 +16,13 @@ import {
type MenuItem =
| { kind: 'separator' }
| { kind: 'command'; commandId: MenuCommandId; label: string }
| {
kind: 'command'
commandId: string
label: string
menuItemId: string
shortcut?: string
}
| { kind: 'action'; action: () => void; label: string; shortcut?: string }
type MenuSection = {
@@ -25,91 +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: 'Paste without Formatting', commandId: APP_COMMAND_IDS.editPastePlainText },
{ 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: [
@@ -121,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() {
@@ -184,15 +83,14 @@ export function LinuxMenuButton() {
}
if (item.kind === 'command') {
const shortcut = getLinuxShortcut(item.commandId)
return (
<DropdownMenuItem
key={item.commandId}
onSelect={() => triggerMenuCommand(item.commandId)}
key={item.menuItemId}
onSelect={() => triggerMenuCommand(item.menuItemId)}
>
<span>{item.label}</span>
{shortcut && (
<DropdownMenuShortcut>{shortcut}</DropdownMenuShortcut>
{item.shortcut && (
<DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>
)}
</DropdownMenuItem>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,40 @@
import { SlidersHorizontal, X, Sparkle, WarningCircle, PencilSimple } from '@phosphor-icons/react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { useDragRegion } from '../../hooks/useDragRegion'
import { translate, type AppLocale } from '../../lib/i18n'
import { hasFrontmatterWarnings, type FrontmatterWarnings } from '../../utils/frontmatter'
export function InspectorHeader({ collapsed, locale = 'en', onToggle }: { collapsed: boolean; locale?: AppLocale; onToggle: () => void }) {
function FrontmatterWarningsButton({ locale, onOpenRawEditor }: { locale: AppLocale; onOpenRawEditor: () => void }) {
const label = translate(locale, 'inspector.title.collidingProperties')
return (
<ActionTooltip copy={{ label }} side="bottom">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 shrink-0 rounded-md border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] p-0 text-[var(--feedback-warning-text)] shadow-none hover:brightness-95"
aria-label={translate(locale, 'inspector.title.collidingPropertiesAria')}
data-testid="frontmatter-warnings-button"
onMouseDown={(event) => event.stopPropagation()}
onClick={onOpenRawEditor}
>
<WarningCircle size={14} weight="fill" aria-hidden="true" />
</Button>
</ActionTooltip>
)
}
export function InspectorHeader({ collapsed, frontmatterWarnings, locale = 'en', onToggle, onOpenRawEditor }: {
collapsed: boolean
frontmatterWarnings?: FrontmatterWarnings
locale?: AppLocale
onToggle: () => void
onOpenRawEditor?: () => void
}) {
const { onMouseDown } = useDragRegion()
const propertiesTitle = translate(locale, 'inspector.title.properties')
const showWarnings = Boolean(frontmatterWarnings && hasFrontmatterWarnings(frontmatterWarnings) && onOpenRawEditor)
return (
<div
@@ -23,7 +53,11 @@ export function InspectorHeader({ collapsed, locale = 'en', onToggle }: { collap
) : (
<>
<SlidersHorizontal size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>{propertiesTitle}</span>
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>{propertiesTitle}</span>
{showWarnings && onOpenRawEditor && (
<FrontmatterWarningsButton locale={locale} onOpenRawEditor={onOpenRawEditor} />
)}
<span className="flex-1" />
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground"
onClick={onToggle}

View File

@@ -63,7 +63,7 @@ vi.mock('../../hooks/useNoteListKeyboard', () => ({
}))
vi.mock('../../hooks/useTabManagement', () => ({
prefetchNoteContent: (path: string) => prefetchNoteContentMock(path),
prefetchNoteContent: (entry: VaultEntry) => prefetchNoteContentMock(entry),
}))
vi.mock('./noteListUtils', async () => {
@@ -475,8 +475,8 @@ describe('noteListHooks extra', () => {
expect(onOpenDeletedNote).toHaveBeenCalledWith(deletedEntry)
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
expect(onAutoTriggerDiff).toHaveBeenCalledOnce()
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry.path)
expect(prefetchNoteContentMock).not.toHaveBeenCalledWith(imageEntry.path)
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry)
expect(prefetchNoteContentMock).not.toHaveBeenCalledWith(imageEntry)
vi.useRealTimers()
})

View File

@@ -1046,7 +1046,7 @@ function useKeyboardInteractionState({
}, [onOpenDeletedNote, onReplaceActiveTab])
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
if (canPrefetchEntryContent(entry)) prefetchNoteContent(entry.path)
if (canPrefetchEntryContent(entry)) prefetchNoteContent(entry)
}, [])
const handleNeighborhoodOpen = useCallback(async (entry: VaultEntry) => {

View File

@@ -32,8 +32,10 @@ import { useChangesContextMenu } from './NoteListChangesMenu'
import { addNoteListSearchToggleListener, dispatchNoteListSearchAvailability } from '../../utils/noteListSearchEvents'
type EntitySelection = Extract<SidebarSelection, { kind: 'entity' }>
const LIKELY_NEXT_PRELOAD_LIMIT = 8
const ADJACENT_PRELOAD_RADIUS = 2
const LIKELY_NEXT_PRELOAD_LIMIT = 6
const ADJACENT_PRELOAD_RADIUS = 3
const LIKELY_NEXT_PRELOAD_START_DELAY_MS = 350
const LIKELY_NEXT_PRELOAD_STEP_DELAY_MS = 180
function useViewFlags(selection: SidebarSelection) {
const isSectionGroup = selection.kind === 'sectionGroup'
@@ -58,7 +60,13 @@ function likelyNextPreloadEntries(entries: VaultEntry[], selectedNotePath: strin
: Math.min(entries.length, LIKELY_NEXT_PRELOAD_LIMIT)
return entries
.slice(start, end)
.filter((entry) => !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary')
.map((entry, offset) => ({ entry, index: start + offset }))
.sort((left, right) => {
if (selectedIndex < 0) return 0
return Math.abs(left.index - selectedIndex) - Math.abs(right.index - selectedIndex)
})
.map(({ entry }) => entry)
.filter((entry) => entry.path !== selectedNotePath && !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary')
.slice(0, LIKELY_NEXT_PRELOAD_LIMIT)
}
@@ -67,11 +75,23 @@ function useLikelyNextPreload(entries: VaultEntry[], selectedNotePath: string |
const candidates = likelyNextPreloadEntries(entries, selectedNotePath)
if (candidates.length === 0) return
const timer = window.setTimeout(() => {
for (const entry of candidates) prefetchNoteContent(entry.path)
}, 100)
let stepTimer: number | null = null
let candidateIndex = 0
const startTimer = window.setTimeout(() => {
const preloadNext = () => {
const entry = candidates[candidateIndex]
if (!entry) return
candidateIndex += 1
prefetchNoteContent(entry)
stepTimer = window.setTimeout(preloadNext, LIKELY_NEXT_PRELOAD_STEP_DELAY_MS)
}
preloadNext()
}, LIKELY_NEXT_PRELOAD_START_DELAY_MS)
return () => window.clearTimeout(timer)
return () => {
window.clearTimeout(startTimer)
if (stepTimer !== null) window.clearTimeout(stepTimer)
}
}, [entries, selectedNotePath])
}

View File

@@ -11,7 +11,7 @@ import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../../utils/
import { NoteTitleIcon } from '../NoteTitleIcon'
import { isSelectionActive } from '../SidebarParts'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
import { SIDEBAR_ITEM_PADDING, SIDEBAR_SECTION_CONTENT_PADDING_BOTTOM } from './sidebarStyles'
import { translate, type AppLocale } from '../../lib/i18n'
const FAVORITE_TYPE_ICON_MAP: Record<string, string> = {
@@ -141,7 +141,7 @@ export function FavoritesSection({
{!collapsed && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={favoriteIds} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: 4 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingBottom: SIDEBAR_SECTION_CONTENT_PADDING_BOTTOM }}>
{favorites.map((entry) => (
<SortableFavoriteItem
key={entry.path}

View File

@@ -4,6 +4,7 @@ import { SlidersHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate, type AppLocale } from '../../lib/i18n'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
interface SidebarLoadingSectionsProps {
collapsed: boolean
@@ -157,7 +158,7 @@ function SidebarLoadingRow({
return (
<div
className="flex select-none items-center gap-2 rounded"
style={{ padding: '6px 8px 6px 16px', borderRadius: 4 }}
style={{ padding: SIDEBAR_ITEM_PADDING.withCount, borderRadius: 4 }}
>
<SidebarLoadingIcon icon={icon} iconColor={iconColor} />
<div className="flex min-w-0 flex-1 items-center">

View File

@@ -1,5 +1,5 @@
import {
type Dispatch, type Ref, type RefObject, type SetStateAction,
type CSSProperties, type Dispatch, type ReactNode, type Ref, type RefObject, type SetStateAction,
} from 'react'
import type {
VaultEntry, SidebarSelection, ViewDefinition, ViewFile,
@@ -13,9 +13,12 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { SlidersHorizontal } from 'lucide-react'
import {
CaretLeft, Palette, PencilSimple, Plus, Trash,
ArrowLeft, ArrowRight, Palette, PencilSimple, Plus, SidebarSimple, Trash,
} from '@phosphor-icons/react'
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../../hooks/appCommandCatalog'
import { Button } from '@/components/ui/button'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
type SectionGroup, isSelectionActive, SectionContent, VisibilityPopover,
} from '../SidebarParts'
@@ -24,12 +27,20 @@ import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { computeReorder } from './sidebarHooks'
import { SIDEBAR_SECTION_CONTENT_PADDING_BOTTOM } from './sidebarStyles'
import { countByFilter } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
const SIDEBAR_TITLE_BAR_ACTION_CLASSNAME =
'!h-auto !w-auto !min-w-0 !rounded-none !p-0 text-muted-foreground hover:!bg-transparent hover:text-foreground [&_svg]:!size-4'
const SIDEBAR_COLLAPSE_SHORTCUT = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewEditorList)
const HISTORY_BACK_SHORTCUT = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoBack)
const HISTORY_FORWARD_SHORTCUT = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoForward)
export interface SidebarSectionProps {
entries: VaultEntry[]
selection: SidebarSelection
@@ -112,7 +123,7 @@ export function ViewsSection({
)}
</SidebarGroupHeader>
{!collapsed && (
<div style={{ paddingBottom: 4 }}>
<div style={{ paddingBottom: SIDEBAR_SECTION_CONTENT_PADDING_BOTTOM }}>
{onReorderViews ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleViewDragEnd}>
<SortableContext items={viewIds} strategy={verticalListSortingStrategy}>
@@ -303,42 +314,116 @@ export function TypesSection({
)}
</div>
{!collapsed && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{visibleSections.map((group) => (
<SortableSection key={group.type} group={group} sectionProps={sectionProps} />
))}
</SortableContext>
</DndContext>
<div style={{ paddingBottom: SIDEBAR_SECTION_CONTENT_PADDING_BOTTOM }}>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
{visibleSections.map((group) => (
<SortableSection key={group.type} group={group} sectionProps={sectionProps} />
))}
</SortableContext>
</DndContext>
</div>
)}
</div>
)
}
export function SidebarTitleBar({ locale = 'en', onCollapse }: { locale?: AppLocale; onCollapse?: () => void }) {
const { onMouseDown } = useDragRegion()
function titleWithShortcut(label: string, shortcut?: string): string {
return shortcut ? `${label} (${shortcut})` : label
}
function SidebarTitleBarAction({
children,
disabled = false,
label,
onClick,
shortcut,
}: {
children: ReactNode
disabled?: boolean
label: string
onClick?: () => void
shortcut?: string
}) {
const title = titleWithShortcut(label, shortcut)
return (
<div
className="shrink-0 flex items-center justify-end border-b border-border"
style={{ height: 52, padding: '0 8px', paddingLeft: 80, cursor: 'default' }}
onMouseDown={onMouseDown}
>
{onCollapse && (
<ActionTooltip copy={{ label, shortcut }} side="bottom" sideOffset={8}>
<span className="inline-flex" title={title} data-no-drag>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground"
onClick={onCollapse}
aria-label={translate(locale, 'sidebar.action.collapse')}
title={translate(locale, 'sidebar.action.collapse')}
className={SIDEBAR_TITLE_BAR_ACTION_CLASSNAME}
onClick={(event) => { event.stopPropagation(); onClick?.() }}
disabled={disabled}
aria-label={label}
title={title}
data-no-drag
>
<CaretLeft size={14} weight="bold" />
{children}
</Button>
)}
</div>
</span>
</ActionTooltip>
)
}
export function SidebarTitleBar({
locale = 'en',
onCollapse,
onGoBack,
onGoForward,
canGoBack = false,
canGoForward = false,
}: {
locale?: AppLocale
onCollapse?: () => void
onGoBack?: () => void
onGoForward?: () => void
canGoBack?: boolean
canGoForward?: boolean
}) {
const { onMouseDown } = useDragRegion()
const collapseLabel = translate(locale, 'sidebar.action.collapse')
const backLabel = translate(locale, 'command.navigation.goBack')
const forwardLabel = translate(locale, 'command.navigation.goForward')
return (
<TooltipProvider>
<div
className="shrink-0 flex items-center border-b border-border"
style={{ height: 52, padding: '0 8px', paddingLeft: 90, cursor: 'default', justifyContent: 'flex-start' }}
onMouseDown={onMouseDown}
>
<div className="flex items-center gap-5" style={{ WebkitAppRegion: 'no-drag' } as CSSProperties}>
{onCollapse && (
<SidebarTitleBarAction label={collapseLabel} shortcut={SIDEBAR_COLLAPSE_SHORTCUT} onClick={onCollapse}>
<SidebarSimple size={16} weight="regular" />
</SidebarTitleBarAction>
)}
{onGoBack && (
<SidebarTitleBarAction
label={backLabel}
shortcut={HISTORY_BACK_SHORTCUT}
onClick={onGoBack}
disabled={!canGoBack}
>
<ArrowLeft size={16} weight="regular" />
</SidebarTitleBarAction>
)}
{onGoForward && (
<SidebarTitleBarAction
label={forwardLabel}
shortcut={HISTORY_FORWARD_SHORTCUT}
onClick={onGoForward}
disabled={!canGoForward}
>
<ArrowRight size={16} weight="regular" />
</SidebarTitleBarAction>
)}
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { SidebarTitleBar } from './SidebarSections'
function renderTitleBar(overrides: Partial<ComponentProps<typeof SidebarTitleBar>> = {}) {
return render(<SidebarTitleBar {...overrides} />)
}
describe('SidebarTitleBar', () => {
it('renders sidebar and history controls with shortcut tooltips', () => {
const onCollapse = vi.fn()
const onGoBack = vi.fn()
const onGoForward = vi.fn()
renderTitleBar({
onCollapse,
onGoBack,
onGoForward,
canGoBack: true,
canGoForward: false,
})
const collapse = screen.getByRole('button', { name: 'Collapse sidebar' })
const back = screen.getByRole('button', { name: 'Go Back' })
const forward = screen.getByRole('button', { name: 'Go Forward' })
expect(collapse).toHaveAttribute('title', expect.stringMatching(/^Collapse sidebar \((⌘|Ctrl\+)2\)$/))
expect(back).toHaveAttribute('title', expect.stringMatching(/^Go Back \((⌘←|Ctrl\+Left)\)$/))
expect(forward).toHaveAttribute('title', expect.stringMatching(/^Go Forward \((⌘→|Ctrl\+Right)\)$/))
expect(forward).toBeDisabled()
fireEvent.click(collapse)
fireEvent.click(back)
fireEvent.click(forward)
expect(onCollapse).toHaveBeenCalledTimes(1)
expect(onGoBack).toHaveBeenCalledTimes(1)
expect(onGoForward).not.toHaveBeenCalled()
})
it('omits controls when sidebar callbacks are absent', () => {
renderTitleBar()
expect(screen.queryByRole('button', { name: 'Collapse sidebar' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Go Back' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Go Forward' })).not.toBeInTheDocument()
})
})

View File

@@ -1,5 +1,5 @@
import {
useEffect, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject,
type ReactNode, type RefObject,
} from 'react'
import {
Palette, PencilSimple, Trash,
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'
import type { ViewDefinition, ViewFile } from '../../types'
import { translate, type AppLocale } from '../../lib/i18n'
import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useSidebarInlineRenameInput } from './sidebarHooks'
export interface MenuPosition {
x: number
@@ -28,23 +29,13 @@ export function ViewRenameInput({
onCancel: () => void
onSubmit: (value: string) => void
}) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, [])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
onSubmit(value)
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
onCancel()
}
}
const {
handleKeyDown,
inputRef,
setValue,
submitValue,
value,
} = useSidebarInlineRenameInput({ initialValue, onCancel, onSubmit })
return (
<Input
@@ -52,7 +43,7 @@ export function ViewRenameInput({
aria-label={translate(locale, 'sidebar.view.name')}
className="h-6 min-w-0 flex-1 rounded border-primary bg-background px-1.5 py-0 text-[13px] font-medium"
value={value}
onBlur={() => onSubmit(value)}
onBlur={() => { void submitValue() }}
onChange={(event) => setValue(event.target.value)}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}

View File

@@ -1,4 +1,8 @@
import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react'
import {
useState, useMemo, useEffect, useCallback, useRef,
type KeyboardEvent as ReactKeyboardEvent,
type RefObject,
} from 'react'
import type { VaultEntry } from '../../types'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage'
import { buildTypeEntryMap } from '../../utils/typeColors'
@@ -8,7 +12,50 @@ import type { AllNotesFileVisibility } from '../../utils/allNotesFileVisibility'
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
export function useOutsideClick(ref: RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
export interface SidebarMenuPosition {
x: number
y: number
}
export interface SidebarContextMenuState<T> {
target: T
pos: SidebarMenuPosition
}
interface PointerMenuEvent {
clientX: number
clientY: number
preventDefault?: () => void
stopPropagation?: () => void
}
interface SidebarInlineRenameInputOptions {
initialValue: string
onCancel: () => void
onSubmit: (value: string) => Promise<boolean> | boolean | void
selectTextOnFocus?: boolean
}
const KEYBOARD_MENU_FALLBACK: SidebarMenuPosition = { x: 20, y: 100 }
export function getPointerMenuPosition(event: PointerMenuEvent): SidebarMenuPosition {
return { x: event.clientX, y: event.clientY }
}
export function getElementMenuPosition(
element: HTMLElement | null,
fallback: SidebarMenuPosition = KEYBOARD_MENU_FALLBACK,
): SidebarMenuPosition {
const bounds = element?.getBoundingClientRect()
if (!bounds) return fallback
return { x: bounds.left + 16, y: bounds.top + bounds.height }
}
export function useOutsideClick<T extends HTMLElement>(
ref: RefObject<T | null>,
isOpen: boolean,
onClose: () => void,
) {
useEffect(() => {
if (!isOpen) return
const handler = (event: MouseEvent) => {
@@ -19,6 +66,97 @@ export function useOutsideClick(ref: RefObject<HTMLElement | null>, isOpen: bool
}, [ref, isOpen, onClose])
}
export function useDismissableSidebarLayer<T extends HTMLElement>(
ref: RefObject<T | null>,
isOpen: boolean,
onClose: () => void,
) {
useOutsideClick(ref, isOpen, onClose)
useEffect(() => {
if (!isOpen) return
const handler = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [isOpen, onClose])
}
export function useSidebarContextMenu<T>() {
const [contextMenu, setContextMenu] = useState<SidebarContextMenuState<T> | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
useDismissableSidebarLayer(contextMenuRef, !!contextMenu, closeContextMenu)
const openContextMenuAt = useCallback((target: T, pos: SidebarMenuPosition) => {
setContextMenu({ target, pos })
}, [])
const openContextMenuFromPointer = useCallback((target: T, event: PointerMenuEvent) => {
event.preventDefault?.()
event.stopPropagation?.()
openContextMenuAt(target, getPointerMenuPosition(event))
}, [openContextMenuAt])
return {
closeContextMenu,
contextMenu,
contextMenuRef,
openContextMenuAt,
openContextMenuFromPointer,
}
}
export function useSidebarInlineRenameInput({
initialValue,
onCancel,
onSubmit,
selectTextOnFocus = true,
}: SidebarInlineRenameInputOptions) {
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 submitValue = useCallback(async () => {
if (submittingRef.current) return false
submittingRef.current = true
try {
return await onSubmit(value)
} finally {
submittingRef.current = false
}
}, [onSubmit, value])
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
void submitValue()
}
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
onCancel()
}
}, [onCancel, submitValue])
return {
handleKeyDown,
inputRef,
setValue,
submitValue,
value,
}
}
export function useSidebarSections(entries: VaultEntry[]) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {

View File

@@ -1,11 +1,13 @@
export const SIDEBAR_ITEM_PADDING = {
regular: '6px 16px',
withCount: '6px 8px 6px 16px',
compact: '4px 16px',
compactWithCount: '4px 8px 4px 16px',
regular: '6px 16px 6px 12px',
withCount: '6px 8px 6px 12px',
compact: '4px 16px 4px 12px',
compactWithCount: '4px 8px 4px 12px',
} as const
export const SIDEBAR_GROUP_HEADER_PADDING = {
regular: '8px 14px 8px 16px',
withCount: '8px 8px 8px 16px',
regular: '8px 14px 8px 12px',
withCount: '8px 8px 8px 12px',
} as const
export const SIDEBAR_SECTION_CONTENT_PADDING_BOTTOM = 8

View File

@@ -1,6 +1,6 @@
import { useCallback, useRef, useState } from 'react'
import type { VaultEntry } from '../../types'
import { applyCustomization, useOutsideClick } from './sidebarHooks'
import { applyCustomization, useOutsideClick, useSidebarContextMenu } from './sidebarHooks'
interface SidebarTypeGroup {
type: string
@@ -18,43 +18,39 @@ interface SidebarTypeInteractionsInput {
function useSidebarTypeState() {
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
const [renamingType, setRenamingType] = useState<string | null>(null)
const [renameInitialValue, setRenameInitialValue] = useState('')
const [showCustomize, setShowCustomize] = useState(false)
const contextMenuRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const customizeRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => {
setContextMenuPos(null)
setContextMenuType(null)
}, [])
const {
closeContextMenu,
contextMenu,
contextMenuRef,
openContextMenuFromPointer,
} = useSidebarContextMenu<string>()
const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), [])
const closeCustomize = useCallback(() => setShowCustomize(false), [])
const cancelRename = useCallback(() => setRenamingType(null), [])
useOutsideClick(customizeRef, showCustomize, closeCustomize)
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
return {
cancelRename,
closeContextMenu,
closeCustomizeTarget,
contextMenuPos,
contextMenuPos: contextMenu?.pos ?? null,
contextMenuRef,
contextMenuType,
contextMenuType: contextMenu?.target ?? null,
customizeRef,
customizeTarget,
openContextMenuFromPointer,
popoverRef,
renameInitialValue,
renamingType,
setContextMenuPos,
setContextMenuType,
setCustomizeTarget,
setRenameInitialValue,
setRenamingType,
@@ -114,10 +110,7 @@ export function useSidebarTypeInteractions({
})
const handleContextMenu = useCallback((event: React.MouseEvent, type: string) => {
event.preventDefault()
event.stopPropagation()
state.setContextMenuPos({ x: event.clientX, y: event.clientY })
state.setContextMenuType(type)
state.openContextMenuFromPointer(type, event)
}, [state])
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {

View File

@@ -3,7 +3,7 @@ import {
type SetStateAction,
} from 'react'
import type { ViewDefinition, ViewFile } from '../../types'
import { useOutsideClick } from './sidebarHooks'
import { getElementMenuPosition, useOutsideClick, useSidebarContextMenu } from './sidebarHooks'
import type { MenuPosition, ViewDefinitionPatchHandler } from './SidebarViewActions'
interface SidebarViewItemInteractionInput {
@@ -16,14 +16,6 @@ interface SidebarViewItemInteractionInput {
type RowKeyboardAction = 'select' | 'rename' | 'menu'
function getKeyboardMenuPosition(row: HTMLDivElement | null): MenuPosition {
const bounds = row?.getBoundingClientRect()
return {
x: bounds ? bounds.left + 16 : 20,
y: bounds ? bounds.top + bounds.height : 100,
}
}
function getRowKeyboardAction(event: KeyboardEvent<HTMLDivElement>): RowKeyboardAction | null {
if (event.key === 'Enter' || event.key === ' ') return 'select'
if (event.key === 'F2') return 'rename'
@@ -43,30 +35,34 @@ function commitViewRename(
}
function useViewInteractionState() {
const [contextMenuPos, setContextMenuPos] = useState<MenuPosition | null>(null)
const [customizePos, setCustomizePos] = useState<MenuPosition | null>(null)
const [isRenaming, setIsRenaming] = useState(false)
const contextMenuRef = useRef<HTMLDivElement>(null)
const customizeRef = useRef<HTMLDivElement>(null)
const rowRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => setContextMenuPos(null), [])
const {
closeContextMenu,
contextMenu,
contextMenuRef,
openContextMenuAt,
openContextMenuFromPointer,
} = useSidebarContextMenu<string>()
const closeCustomize = useCallback(() => setCustomizePos(null), [])
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
useOutsideClick(customizeRef, !!customizePos, closeCustomize)
return {
closeContextMenu,
closeCustomize,
contextMenuPos,
contextMenuPos: contextMenu?.pos ?? null,
contextMenuRef,
customizePos,
customizeRef,
isRenaming,
rowRef,
setContextMenuPos,
setCustomizePos,
setIsRenaming,
openContextMenuAt,
openContextMenuFromPointer,
}
}
@@ -104,30 +100,30 @@ function useViewMenuActions({
onUpdateViewDefinition,
closeContextMenu,
contextMenuPos,
openContextMenuAt,
openContextMenuFromPointer,
rowRef,
setContextMenuPos,
setCustomizePos,
}: SidebarViewItemInteractionInput & {
closeContextMenu: () => void
contextMenuPos: MenuPosition | null
openContextMenuAt: (target: string, pos: MenuPosition) => void
openContextMenuFromPointer: (target: string, event: MouseEvent) => void
rowRef: RefObject<HTMLDivElement | null>
setContextMenuPos: Dispatch<SetStateAction<MenuPosition | null>>
setCustomizePos: Dispatch<SetStateAction<MenuPosition | null>>
}) {
const hasMenuActions = !!(onEditView || onDeleteView || onUpdateViewDefinition)
const handleContextMenu = useCallback((event: MouseEvent) => {
if (!hasMenuActions) return
event.preventDefault()
event.stopPropagation()
setCustomizePos(null)
setContextMenuPos({ x: event.clientX, y: event.clientY })
}, [hasMenuActions, setContextMenuPos, setCustomizePos])
openContextMenuFromPointer(view.filename, event)
}, [hasMenuActions, openContextMenuFromPointer, setCustomizePos, view.filename])
const openKeyboardContextMenu = useCallback(() => {
setCustomizePos(null)
setContextMenuPos(getKeyboardMenuPosition(rowRef.current))
}, [rowRef, setContextMenuPos, setCustomizePos])
openContextMenuAt(view.filename, getElementMenuPosition(rowRef.current))
}, [openContextMenuAt, rowRef, setCustomizePos, view.filename])
const handleEdit = useCallback(() => {
closeContextMenu()
@@ -205,8 +201,9 @@ export function useSidebarViewItemInteractions({
onUpdateViewDefinition,
closeContextMenu: state.closeContextMenu,
contextMenuPos: state.contextMenuPos,
openContextMenuAt: state.openContextMenuAt,
openContextMenuFromPointer: state.openContextMenuFromPointer,
rowRef: state.rowRef,
setContextMenuPos: state.setContextMenuPos,
setCustomizePos: state.setCustomizePos,
})
const handleRowKeyDown = useViewRowKeyboardActions({

View File

@@ -1,21 +1,173 @@
import { render, screen } from '@testing-library/react'
import type { ComponentType, PropsWithChildren } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import type { DragEventHandler, PropsWithChildren, ReactNode } from 'react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
let capturedDragHandleMenu: ComponentType | null = null
type MockBlock = {
children?: MockBlock[]
id: string
type: string
content?: unknown
}
type SideMenuButtonProps = {
draggable?: boolean
icon?: ReactNode
label: string
onClick?: () => void
onDragEnd?: DragEventHandler<HTMLButtonElement>
onDragStart?: DragEventHandler<HTMLButtonElement>
}
type MenuItemProps = PropsWithChildren<{
checked?: boolean
className?: string
onClick?: () => void
}>
type MockEditor = {
domElement: HTMLElement
focus: ReturnType<typeof vi.fn>
getBlock: ReturnType<typeof vi.fn>
insertBlocks: ReturnType<typeof vi.fn>
removeBlocks: ReturnType<typeof vi.fn>
setTextCursorPosition: ReturnType<typeof vi.fn>
settings: { tables: { headers: boolean } }
transact: ReturnType<typeof vi.fn>
updateBlock: ReturnType<typeof vi.fn>
}
let mockEditor: MockEditor
let mockSideMenu: {
blockDragEnd: ReturnType<typeof vi.fn>
blockDragStart: ReturnType<typeof vi.fn>
freezeMenu: ReturnType<typeof vi.fn>
unfreezeMenu: ReturnType<typeof vi.fn>
}
let mockSuggestionMenu: { openSuggestionMenu: ReturnType<typeof vi.fn> }
let sideMenuBlock: MockBlock | undefined
const originalElementsFromPoint = document.elementsFromPoint
beforeAll(() => {
if (typeof globalThis.PointerEvent !== 'undefined') return
class TestPointerEvent extends MouseEvent {
readonly isPrimary: boolean
readonly pointerId: number
constructor(type: string, init: PointerEventInit = {}) {
super(type, init)
this.isPrimary = init.isPrimary ?? true
this.pointerId = init.pointerId ?? 1
}
}
Object.defineProperty(globalThis, 'PointerEvent', {
configurable: true,
value: TestPointerEvent,
})
})
function targetBlockId(block: MockBlock | string) {
return typeof block === 'string' ? block : block.id
}
function staleBlockError(block: MockBlock | string) {
return new Error(`Block with ID ${targetBlockId(block)} not found`)
}
function requireLiveBlock(block: MockBlock | string) {
const liveBlock = mockEditor.getBlock(targetBlockId(block))
if (!liveBlock) throw staleBlockError(block)
return liveBlock
}
vi.mock('@blocknote/core/extensions', () => ({
SideMenuExtension: { key: 'side-menu' },
SuggestionMenu: { key: 'suggestion-menu' },
}))
vi.mock('@blocknote/react', () => ({
AddBlockButton: () => (
<button
type="button"
onClick={() => {
if (!sideMenuBlock) return
const blockContent = sideMenuBlock.content
const isBlockEmpty = Array.isArray(blockContent) && blockContent.length === 0
if (isBlockEmpty) {
mockEditor.setTextCursorPosition(sideMenuBlock)
mockSuggestionMenu.openSuggestionMenu('/')
} else {
const insertedBlock = mockEditor.insertBlocks([{ type: 'paragraph' }], sideMenuBlock, 'after')[0]
mockEditor.setTextCursorPosition(insertedBlock)
mockSuggestionMenu.openSuggestionMenu('/')
}
}}
>
Add block
</button>
),
DragHandleMenu: ({ children }: PropsWithChildren) => (
<div data-testid="drag-handle-menu">{children}</div>
),
RemoveBlockItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
SideMenu: ({ dragHandleMenu }: { dragHandleMenu?: ComponentType }) => {
capturedDragHandleMenu = dragHandleMenu ?? null
return <div data-testid="side-menu" />
DragHandleButton: () => {
return (
<button
type="button"
draggable
onDragStart={() => {
if (sideMenuBlock) mockSideMenu.blockDragStart({ dataTransfer: null, clientY: 10 }, sideMenuBlock)
}}
>
Drag block
</button>
)
},
TableColumnHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
TableRowHeaderItem: ({ children }: PropsWithChildren) => <div>{children}</div>,
RemoveBlockItem: ({ children }: PropsWithChildren) => (
<div
role="menuitem"
onClick={() => {
if (sideMenuBlock) mockEditor.removeBlocks([sideMenuBlock])
}}
>
{children}
</div>
),
SideMenu: ({ children }: PropsWithChildren) => <div data-testid="side-menu">{children}</div>,
useBlockNoteEditor: () => mockEditor,
useComponentsContext: () => ({
Generic: {
Menu: {
Item: ({ children, onClick }: MenuItemProps) => (
<div role="menuitem" onClick={onClick}>{children}</div>
),
Root: ({ children, onOpenChange }: PropsWithChildren<{ onOpenChange?: (open: boolean) => void }>) => (
<div
data-testid="menu-root"
onClick={() => onOpenChange?.(true)}
>
{children}
</div>
),
Trigger: ({ children }: PropsWithChildren) => <div>{children}</div>,
},
},
SideMenu: {
Button: ({ draggable, label, onClick, onDragEnd, onDragStart }: SideMenuButtonProps) => (
<button
type="button"
draggable={draggable}
onClick={onClick}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
>
{label}
</button>
),
},
}),
useDictionary: () => ({
drag_handle: {
delete_menuitem: 'Delete',
@@ -23,22 +175,263 @@ vi.mock('@blocknote/react', () => ({
header_column_menuitem: 'Header column',
colors_menuitem: 'Colors',
},
side_menu: {
add_block_label: 'Add block',
drag_handle_label: 'Drag block',
},
}),
useExtension: (extension: { key: string }) => (
extension.key === 'suggestion-menu' ? mockSuggestionMenu : mockSideMenu
),
useExtensionState: (_extension: unknown, options?: { selector?: (state: { block?: MockBlock }) => unknown }) => (
options?.selector ? options.selector({ block: sideMenuBlock }) : { block: sideMenuBlock }
),
}))
function renderSideMenuWithBlock(block: MockBlock | undefined) {
sideMenuBlock = block
render(<TolariaSideMenu />)
}
function rect(left: number, top: number, width: number, height: number) {
return DOMRect.fromRect({ x: left, y: top, width, height })
}
function blockElement(id: string, bounds: DOMRect) {
const element = document.createElement('div')
element.dataset.id = id
element.dataset.nodeType = 'blockContainer'
element.getBoundingClientRect = vi.fn(() => bounds)
return element
}
function dispatchPointerEvent(
target: EventTarget,
type: 'pointerdown' | 'pointermove' | 'pointerup',
init: PointerEventInit,
) {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
isPrimary: true,
pointerId: 1,
...init,
}))
}
function testBlock(id: string, type: string, content: unknown): MockBlock {
return { id, type, content, children: [] }
}
function dispatchHandlePointerReorder(dragHandle: HTMLElement) {
dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 80, clientY: 90 })
dispatchPointerEvent(document, 'pointermove', { clientX: 130, clientY: 122 })
dispatchPointerEvent(document, 'pointerup', { clientX: 130, clientY: 122 })
}
function renderPointerReorderFixture() {
const draggedBlock = testBlock('dragged-block', 'heading', ['Notes'])
const targetBlock = testBlock('target-block', 'paragraph', ['Paragraph'])
const draggedElement = blockElement(draggedBlock.id, rect(120, 80, 420, 40))
const targetElement = blockElement(targetBlock.id, rect(120, 120, 420, 40))
mockEditor.domElement.append(draggedElement, targetElement)
mockEditor.getBlock.mockImplementation((id: string) => (
id === draggedBlock.id ? draggedBlock
: id === targetBlock.id ? targetBlock
: undefined
))
document.elementsFromPoint = vi.fn(() => [targetElement, mockEditor.domElement])
renderSideMenuWithBlock(draggedBlock)
return {
draggedBlock,
draggedElement,
dragHandle: screen.getByRole('button', { name: 'Drag block' }),
targetBlock,
}
}
describe('TolariaSideMenu', () => {
beforeEach(() => {
const editorElement = document.createElement('div')
editorElement.className = 'bn-editor'
editorElement.getBoundingClientRect = vi.fn(() => rect(100, 50, 500, 400))
document.body.appendChild(editorElement)
sideMenuBlock = {
id: 'stale-block',
type: 'paragraph',
content: ['old text'],
children: [],
}
mockEditor = {
domElement: editorElement,
focus: vi.fn(),
getBlock: vi.fn(() => undefined),
insertBlocks: vi.fn((_blocks, block: MockBlock | string) => {
requireLiveBlock(block)
return [{ id: 'inserted-block', type: 'paragraph', content: [] }]
}),
removeBlocks: vi.fn((blocks: Array<MockBlock | string>) => {
blocks.forEach(requireLiveBlock)
return blocks
}),
setTextCursorPosition: vi.fn((block: MockBlock | string) => {
requireLiveBlock(block)
}),
settings: { tables: { headers: true } },
transact: vi.fn((callback: () => void) => callback()),
updateBlock: vi.fn((block: MockBlock | string) => {
requireLiveBlock(block)
return block
}),
}
mockSideMenu = {
blockDragEnd: vi.fn(),
blockDragStart: vi.fn((_event, block: MockBlock) => {
requireLiveBlock(block)
}),
freezeMenu: vi.fn(),
unfreezeMenu: vi.fn(),
}
mockSuggestionMenu = { openSuggestionMenu: vi.fn() }
})
afterEach(() => {
document.elementsFromPoint = originalElementsFromPoint
document.body.innerHTML = ''
})
it('replaces BlockNote block colors with markdown-safe drag-handle items', () => {
render(<TolariaSideMenu />)
mockEditor.getBlock.mockReturnValue(sideMenuBlock)
renderSideMenuWithBlock(sideMenuBlock)
expect(screen.getByTestId('side-menu')).toBeInTheDocument()
expect(capturedDragHandleMenu).not.toBeNull()
const DragHandleMenuComponent = capturedDragHandleMenu!
render(<DragHandleMenuComponent />)
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Add block',
'Drag block',
])
expect(screen.getByText('Delete')).toBeInTheDocument()
expect(screen.getByText('Header row')).toBeInTheDocument()
expect(screen.getByText('Header column')).toBeInTheDocument()
expect(screen.queryByText('Colors')).not.toBeInTheDocument()
})
it('ignores add-block clicks when reload churn leaves the side menu with a stale block', () => {
renderSideMenuWithBlock(sideMenuBlock)
expect(() => fireEvent.click(screen.getByRole('button', { name: 'Add block' }))).not.toThrow()
expect(mockEditor.insertBlocks).not.toHaveBeenCalled()
expect(mockEditor.setTextCursorPosition).not.toHaveBeenCalled()
expect(mockSuggestionMenu.openSuggestionMenu).not.toHaveBeenCalled()
})
it('resolves the live block before adding a block after reload churn', () => {
const staleBlock = { id: 'same-id', type: 'paragraph', content: [] }
const liveBlock = { id: 'same-id', type: 'paragraph', content: ['fresh text'] }
mockEditor.getBlock.mockReturnValue(liveBlock)
renderSideMenuWithBlock(staleBlock)
fireEvent.click(screen.getByRole('button', { name: 'Add block' }))
expect(mockEditor.insertBlocks).toHaveBeenCalledWith([{ type: 'paragraph' }], liveBlock.id, 'after')
expect(mockEditor.setTextCursorPosition).toHaveBeenCalledWith('inserted-block')
expect(mockSuggestionMenu.openSuggestionMenu).toHaveBeenCalledWith('/')
})
it('ignores delete clicks when the side-menu block disappeared during a reload', () => {
renderSideMenuWithBlock(sideMenuBlock)
expect(() => fireEvent.click(screen.getByText('Delete'))).not.toThrow()
expect(mockEditor.removeBlocks).not.toHaveBeenCalled()
})
it('resolves the live table block before toggling table headers', () => {
const staleTable = {
id: 'table-block',
type: 'table',
content: { type: 'tableContent', rows: [], headerRows: undefined },
}
const liveTable = {
id: 'table-block',
type: 'table',
content: { type: 'tableContent', rows: [], headerRows: undefined },
}
mockEditor.getBlock.mockReturnValue(liveTable)
renderSideMenuWithBlock(staleTable)
fireEvent.click(screen.getByText('Header row'))
expect(mockEditor.updateBlock).toHaveBeenCalledWith(liveTable.id, {
content: { ...liveTable.content, headerRows: 1 },
})
})
it('ignores stale drag starts after reload churn', () => {
renderSideMenuWithBlock(sideMenuBlock)
expect(() => fireEvent.dragStart(screen.getByRole('button', { name: 'Drag block' }))).not.toThrow()
expect(mockSideMenu.blockDragStart).not.toHaveBeenCalled()
})
it('reorders blocks with pointer movement instead of BlockNote HTML drag data', () => {
const { draggedBlock, dragHandle, targetBlock } = renderPointerReorderFixture()
dispatchHandlePointerReorder(dragHandle)
expect(mockSideMenu.blockDragStart).not.toHaveBeenCalled()
expect(mockEditor.focus).toHaveBeenCalled()
expect(mockEditor.transact).toHaveBeenCalled()
expect(mockEditor.removeBlocks).toHaveBeenCalledWith([draggedBlock.id])
expect(mockEditor.insertBlocks).toHaveBeenCalledWith([draggedBlock], targetBlock.id, 'before')
})
it('shows and clears pointer reorder affordances while dragging', () => {
const { draggedElement, dragHandle } = renderPointerReorderFixture()
dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 140, clientY: 90 })
dispatchPointerEvent(document, 'pointermove', { clientX: 180, clientY: 122 })
const preview = screen.getByTestId('editor-block-drag-preview')
const indicator = screen.getByTestId('editor-block-drop-indicator')
expect(preview).toHaveStyle({
left: '160px',
opacity: '0.72',
top: '112px',
})
expect(indicator).toHaveStyle({
display: 'block',
left: '120px',
top: '119px',
width: '420px',
})
expect(draggedElement).toHaveStyle({ opacity: '0.35' })
dispatchPointerEvent(document, 'pointerup', { clientX: 180, clientY: 122 })
expect(screen.queryByTestId('editor-block-drag-preview')).not.toBeInTheDocument()
expect(screen.queryByTestId('editor-block-drop-indicator')).not.toBeInTheDocument()
expect(draggedElement.style.opacity).toBe('')
})
it('keeps click-to-open menu behavior when the handle does not move', () => {
mockEditor.getBlock.mockReturnValue(sideMenuBlock)
renderSideMenuWithBlock(sideMenuBlock)
const dragHandle = screen.getByRole('button', { name: 'Drag block' })
dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 80, clientY: 90 })
dispatchPointerEvent(document, 'pointerup', { clientX: 80, clientY: 90 })
fireEvent.click(dragHandle)
expect(mockSideMenu.freezeMenu).toHaveBeenCalled()
})
it('suppresses the follow-up menu click after a pointer reorder', () => {
const { dragHandle } = renderPointerReorderFixture()
dispatchHandlePointerReorder(dragHandle)
fireEvent.click(dragHandle)
expect(mockSideMenu.freezeMenu).not.toHaveBeenCalled()
})
})

View File

@@ -1,25 +1,796 @@
import { SideMenuExtension, SuggestionMenu } from '@blocknote/core/extensions'
import type {
BlockNoteEditor,
BlockSchema,
InlineContentSchema,
StyleSchema,
} from '@blocknote/core'
import {
DragHandleMenu,
RemoveBlockItem,
SideMenu,
TableColumnHeaderItem,
TableRowHeaderItem,
type SideMenuProps,
useBlockNoteEditor,
useComponentsContext,
useDictionary,
useExtension,
useExtensionState,
type SideMenuProps,
} from '@blocknote/react'
import { GripVertical, Plus } from 'lucide-react'
import {
useCallback,
useLayoutEffect,
useRef,
type ComponentType,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react'
type TolariaBlockNoteEditor = BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
type TolariaBlock = NonNullable<ReturnType<TolariaBlockNoteEditor['getBlock']>>
type SideMenuBlock = {
content?: unknown
id: string
type: string
}
type TableHeaderContent = Record<string, unknown> & {
headerCols?: unknown
headerRows?: unknown
}
type DropPlacement = 'before' | 'after'
type PointerReorderState = {
affordances?: ReorderAffordances
clearListeners: () => void
draggedBlockId: string
editorElement: HTMLElement
hasMoved: boolean
lastDropTarget?: DropTarget | null
ownerDocument: Document
pointerId: number
startX: number
startY: number
}
type ReorderAffordances = {
draggedElement: HTMLElement
dropIndicator: HTMLElement
pointerOffsetX: number
pointerOffsetY: number
preview: HTMLElement
previousDraggedOpacity: string
}
type DropTarget = {
blockId: string
element: HTMLElement
placement: DropPlacement
}
type SideMenuAlignmentState = {
attemptsRemaining: number
frame: number | null
hasObservedTargets: boolean
}
type SideMenuAlignmentContext = {
blockId: string
editorElement: HTMLElement
observeTargets: () => void
ownerWindow: Window
retry: () => void
state: SideMenuAlignmentState
}
const BLOCK_CONTAINER_SELECTOR = '[data-node-type="blockContainer"][data-id]'
const POINTER_REORDER_THRESHOLD_PX = 4
const SIDE_MENU_ALIGNMENT_ATTEMPTS = 8
function liveSideMenuBlock(editor: TolariaBlockNoteEditor, block: SideMenuBlock | undefined) {
if (!block) return undefined
return editor.getBlock(block.id)
}
function runSideMenuAction(action: () => void) {
try {
action()
} catch (error) {
console.warn('[editor] Ignored stale block side-menu action:', error)
}
}
function isInlineBlockEmpty(block: { content?: unknown }) {
return Array.isArray(block.content) && block.content.length === 0
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function tableHeaderContent(block: unknown): TableHeaderContent | undefined {
if (!isRecord(block) || block.type !== 'table' || !isRecord(block.content)) return undefined
return block.content
}
function hasChildBlock(block: TolariaBlock, blockId: string): boolean {
for (const child of block.children) {
if (child.id === blockId || hasChildBlock(child, blockId)) return true
}
return false
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function editorBlockElement(editor: TolariaBlockNoteEditor): HTMLElement | null {
const element = editor.domElement
if (!(element instanceof HTMLElement)) return null
return element.matches('.bn-editor')
? element
: element.querySelector('.bn-editor')
}
function blockElementFromPoint({
editorElement,
ownerDocument,
x,
y,
}: {
editorElement: HTMLElement
ownerDocument: Document
x: number
y: number
}): HTMLElement | null {
if (typeof ownerDocument.elementsFromPoint !== 'function') return null
const editorRect = editorElement.getBoundingClientRect()
if (editorRect.width <= 0 || editorRect.height <= 0) return null
const hitX = clamp(x, editorRect.left + 10, editorRect.right - 10)
const hitY = clamp(y, editorRect.top + 1, editorRect.bottom - 1)
for (const element of ownerDocument.elementsFromPoint(hitX, hitY)) {
if (!editorElement.contains(element)) continue
const blockElement = element.closest(BLOCK_CONTAINER_SELECTOR)
if (blockElement instanceof HTMLElement && editorElement.contains(blockElement)) {
return blockElement
}
}
return null
}
function dropPlacementForPoint(blockElement: HTMLElement, y: number): DropPlacement {
const rect = blockElement.getBoundingClientRect()
return y < rect.top + rect.height / 2 ? 'before' : 'after'
}
function blockIdFromElement(blockElement: HTMLElement): string | null {
return blockElement.dataset.id ?? null
}
function blockElementById(editorElement: HTMLElement, blockId: string): HTMLElement | null {
for (const element of editorElement.querySelectorAll(BLOCK_CONTAINER_SELECTOR)) {
if (element instanceof HTMLElement && element.dataset.id === blockId) return element
}
return null
}
function sideMenuElementForEditor(editorElement: HTMLElement): HTMLElement | null {
const container = editorElement.closest('.editor__blocknote-container') ?? editorElement
const sideMenu = container.querySelector('.bn-side-menu')
return sideMenu instanceof HTMLElement ? sideMenu : null
}
function blockTextAnchorRect(blockElement: HTMLElement): DOMRect | null {
const content = blockElement.querySelector('.bn-block-content')
const inlineContent = content?.querySelector('.bn-inline-content') ?? content
if (!(inlineContent instanceof HTMLElement)) return null
const ownerDocument = inlineContent.ownerDocument
const range = ownerDocument.createRange()
range.selectNodeContents(inlineContent)
const textRect = range.getBoundingClientRect()
range.detach()
if (textRect.height > 0) return textRect
const fallbackRect = inlineContent.getBoundingClientRect()
return fallbackRect.height > 0 ? fallbackRect : null
}
function alignSideMenuWithBlockText(editorElement: HTMLElement, blockId: string): boolean {
const blockElement = blockElementById(editorElement, blockId)
const sideMenu = sideMenuElementForEditor(editorElement)
if (!blockElement || !sideMenu) return false
const anchorRect = blockTextAnchorRect(blockElement)
if (!anchorRect) return false
sideMenu.style.removeProperty('translate')
const sideMenuRect = sideMenu.getBoundingClientRect()
if (sideMenuRect.height <= 0) return false
const anchorCenter = anchorRect.top + anchorRect.height / 2
const sideMenuCenter = sideMenuRect.top + sideMenuRect.height / 2
sideMenu.style.setProperty('translate', `0 ${anchorCenter - sideMenuCenter}px`)
return true
}
function createSideMenuAlignmentState(): SideMenuAlignmentState {
return {
attemptsRemaining: SIDE_MENU_ALIGNMENT_ATTEMPTS,
frame: null,
hasObservedTargets: false,
}
}
function createSideMenuResizeObserver(onResize: () => void): ResizeObserver | null {
return typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(onResize)
}
function observeSideMenuAlignmentTargets({
blockId,
editorElement,
resizeObserver,
state,
}: {
blockId: string
editorElement: HTMLElement
resizeObserver: ResizeObserver | null
state: SideMenuAlignmentState
}) {
if (state.hasObservedTargets) return
const blockElement = blockElementById(editorElement, blockId)
const sideMenu = sideMenuElementForEditor(editorElement)
if (!resizeObserver || !blockElement || !sideMenu) return
resizeObserver.observe(blockElement)
resizeObserver.observe(sideMenu)
state.hasObservedTargets = true
}
function scheduleSideMenuTextAlignment(context: SideMenuAlignmentContext) {
const { blockId, editorElement, observeTargets, ownerWindow, retry, state } = context
if (state.frame !== null) return
state.frame = ownerWindow.requestAnimationFrame(() => {
state.frame = null
const aligned = alignSideMenuWithBlockText(editorElement, blockId)
observeTargets()
if (!aligned && state.attemptsRemaining > 0) {
state.attemptsRemaining -= 1
retry()
}
})
}
function createSideMenuAlignmentCleanup({
editorElement,
ownerWindow,
resizeObserver,
scheduleAlignment,
state,
}: {
editorElement: HTMLElement
ownerWindow: Window
resizeObserver: ResizeObserver | null
scheduleAlignment: () => void
state: SideMenuAlignmentState
}) {
return () => {
if (state.frame !== null) ownerWindow.cancelAnimationFrame(state.frame)
resizeObserver?.disconnect()
ownerWindow.removeEventListener('resize', scheduleAlignment)
sideMenuElementForEditor(editorElement)?.style.removeProperty('translate')
}
}
function createSideMenuAlignmentController(editor: TolariaBlockNoteEditor, blockId: string) {
const editorElement = editorBlockElement(editor)
const ownerWindow = editorElement?.ownerDocument.defaultView
if (!editorElement || !ownerWindow) return undefined
const state = createSideMenuAlignmentState()
let resizeObserver: ResizeObserver | null = null
const observeTargets = () => observeSideMenuAlignmentTargets({
blockId,
editorElement,
resizeObserver,
state,
})
const scheduleAlignment = () => scheduleSideMenuTextAlignment({
blockId,
editorElement,
observeTargets,
ownerWindow,
retry: scheduleAlignment,
state,
})
resizeObserver = createSideMenuResizeObserver(scheduleAlignment)
scheduleAlignment()
observeTargets()
ownerWindow.addEventListener('resize', scheduleAlignment)
return createSideMenuAlignmentCleanup({
editorElement,
ownerWindow,
resizeObserver,
scheduleAlignment,
state,
})
}
function useSideMenuTextAlignment(editor: TolariaBlockNoteEditor, block: SideMenuBlock | undefined) {
useLayoutEffect(() => {
if (!block) return
return createSideMenuAlignmentController(editor, block.id)
}, [block?.id, editor])
}
function styleDragPreview(preview: HTMLElement, rect: DOMRect) {
preview.setAttribute('data-testid', 'editor-block-drag-preview')
preview.setAttribute('aria-hidden', 'true')
preview.className = 'editor__blocknote-container'
preview.style.position = 'fixed'
preview.style.width = `${rect.width}px`
preview.style.maxHeight = `${Math.max(rect.height, 1)}px`
preview.style.overflow = 'hidden'
preview.style.pointerEvents = 'none'
preview.style.opacity = '0.72'
preview.style.zIndex = '14000'
preview.style.boxSizing = 'border-box'
preview.style.borderRadius = '6px'
preview.style.background = 'var(--bg-primary, white)'
preview.style.boxShadow = '0 10px 26px rgba(15, 23, 42, 0.18)'
}
function createDragPreview(draggedElement: HTMLElement, ownerDocument: Document): HTMLElement {
const preview = ownerDocument.createElement('div')
const clone = draggedElement.cloneNode(true)
const rect = draggedElement.getBoundingClientRect()
if (clone instanceof HTMLElement) {
clone.style.margin = '0'
clone.style.width = '100%'
clone.style.pointerEvents = 'none'
preview.appendChild(clone)
}
styleDragPreview(preview, rect)
ownerDocument.body.appendChild(preview)
return preview
}
function createDropIndicator(ownerDocument: Document): HTMLElement {
const indicator = ownerDocument.createElement('div')
indicator.setAttribute('data-testid', 'editor-block-drop-indicator')
indicator.style.position = 'fixed'
indicator.style.height = '2px'
indicator.style.pointerEvents = 'none'
indicator.style.background = 'var(--border-focus, #155dff)'
indicator.style.borderRadius = '999px'
indicator.style.boxShadow = '0 0 0 1px rgba(21, 93, 255, 0.12), 0 0 10px rgba(21, 93, 255, 0.28)'
indicator.style.zIndex = '14001'
indicator.style.display = 'none'
ownerDocument.body.appendChild(indicator)
return indicator
}
function createReorderAffordances(state: PointerReorderState): ReorderAffordances | undefined {
const draggedElement = blockElementById(state.editorElement, state.draggedBlockId)
if (!draggedElement) return undefined
const rect = draggedElement.getBoundingClientRect()
const previousDraggedOpacity = draggedElement.style.opacity
const preview = createDragPreview(draggedElement, state.ownerDocument)
draggedElement.style.opacity = '0.35'
return {
draggedElement,
dropIndicator: createDropIndicator(state.ownerDocument),
pointerOffsetX: state.startX - rect.left,
pointerOffsetY: state.startY - rect.top,
preview,
previousDraggedOpacity,
}
}
function cleanupReorderAffordances(affordances: ReorderAffordances | undefined) {
if (!affordances) return
affordances.draggedElement.style.opacity = affordances.previousDraggedOpacity
affordances.preview.remove()
affordances.dropIndicator.remove()
}
function updateDragPreview(affordances: ReorderAffordances, x: number, y: number) {
affordances.preview.style.left = `${x - affordances.pointerOffsetX}px`
affordances.preview.style.top = `${y - affordances.pointerOffsetY}px`
}
function hideDropIndicator(affordances: ReorderAffordances | undefined) {
if (affordances) affordances.dropIndicator.style.display = 'none'
}
function updateDropIndicator(affordances: ReorderAffordances | undefined, target: DropTarget | null) {
if (!affordances || !target) {
hideDropIndicator(affordances)
return
}
const rect = target.element.getBoundingClientRect()
affordances.dropIndicator.style.display = 'block'
affordances.dropIndicator.style.left = `${rect.left}px`
affordances.dropIndicator.style.top = `${target.placement === 'before' ? rect.top - 1 : rect.bottom - 1}px`
affordances.dropIndicator.style.width = `${rect.width}px`
}
function validDropTarget({
editor,
state,
x,
y,
}: {
editor: TolariaBlockNoteEditor
state: PointerReorderState
x: number
y: number
}): DropTarget | null {
const targetElement = blockElementFromPoint({
editorElement: state.editorElement,
ownerDocument: state.ownerDocument,
x,
y,
})
if (!targetElement) return null
const blockId = blockIdFromElement(targetElement)
if (!blockId || blockId === state.draggedBlockId) return null
const draggedBlock = editor.getBlock(state.draggedBlockId)
const targetBlock = editor.getBlock(blockId)
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, blockId)) return null
return {
blockId,
element: targetElement,
placement: dropPlacementForPoint(targetElement, y),
}
}
function moveBlockByPointerDrop({
editor,
draggedBlockId,
targetBlockId,
placement,
}: {
editor: TolariaBlockNoteEditor
draggedBlockId: string
targetBlockId: string
placement: DropPlacement
}): boolean {
if (draggedBlockId === targetBlockId) return false
const draggedBlock = editor.getBlock(draggedBlockId)
const targetBlock = editor.getBlock(targetBlockId)
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, targetBlockId)) return false
let moved = false
editor.focus()
editor.transact(() => {
const currentDraggedBlock = editor.getBlock(draggedBlockId)
const currentTargetBlock = editor.getBlock(targetBlockId)
if (!currentDraggedBlock || !currentTargetBlock) return
if (hasChildBlock(currentDraggedBlock, targetBlockId)) return
editor.removeBlocks([currentDraggedBlock.id])
editor.insertBlocks([currentDraggedBlock], currentTargetBlock.id, placement)
moved = true
})
return moved
}
function useSideMenuBlock() {
const editor = useBlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>()
const block = useExtensionState(SideMenuExtension, {
editor,
selector: (state): SideMenuBlock | undefined => state?.block
? {
content: state.block.content,
id: state.block.id,
type: state.block.type,
}
: undefined,
})
return { block, editor }
}
function TolariaAddBlockButton() {
const Components = useComponentsContext()!
const dict = useDictionary()
const suggestionMenu = useExtension(SuggestionMenu)
const { block, editor } = useSideMenuBlock()
const onClick = useCallback(() => {
runSideMenuAction(() => {
const liveBlock = liveSideMenuBlock(editor, block)
if (!liveBlock) return
if (isInlineBlockEmpty(liveBlock)) {
editor.setTextCursorPosition(liveBlock.id)
suggestionMenu.openSuggestionMenu('/')
return
}
const insertedBlock = editor.insertBlocks([{ type: 'paragraph' }], liveBlock.id, 'after')[0]
if (!insertedBlock) return
editor.setTextCursorPosition(insertedBlock.id)
suggestionMenu.openSuggestionMenu('/')
})
}, [block, editor, suggestionMenu])
if (!block) return null
return (
<Components.SideMenu.Button
className="bn-button"
label={dict.side_menu.add_block_label}
onClick={onClick}
icon={<Plus size={20} data-test="dragHandleAdd" />}
/>
)
}
function TolariaDragHandleButton({
children,
dragHandleMenu,
}: SideMenuProps & { children?: ReactNode }) {
const Components = useComponentsContext()!
const dict = useDictionary()
const sideMenu = useExtension(SideMenuExtension)
const { block, editor } = useSideMenuBlock()
const MenuComponent: ComponentType<{ children?: ReactNode }> = dragHandleMenu ?? DragHandleMenu
const reorderStateRef = useRef<PointerReorderState | null>(null)
const suppressNextClickRef = useRef(false)
const clearReorderState = useCallback(() => {
const state = reorderStateRef.current
if (state) {
state.clearListeners()
cleanupReorderAffordances(state.affordances)
}
reorderStateRef.current = null
}, [])
const finishPointerReorder = useCallback((event: PointerEvent) => {
const state = reorderStateRef.current
if (!state || event.pointerId !== state.pointerId) return
clearReorderState()
if (!state.hasMoved) return
event.preventDefault()
suppressNextClickRef.current = true
const dropTarget = state.lastDropTarget ?? validDropTarget({
editor,
state,
x: event.clientX,
y: event.clientY,
})
if (!dropTarget) return
const moved = moveBlockByPointerDrop({
editor,
draggedBlockId: state.draggedBlockId,
targetBlockId: dropTarget.blockId,
placement: dropTarget.placement,
})
if (!moved) suppressNextClickRef.current = false
}, [clearReorderState, editor])
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if ((typeof event.button === 'number' && event.button !== 0) || event.isPrimary === false) return
runSideMenuAction(() => {
const liveBlock = liveSideMenuBlock(editor, block)
const editorElement = editorBlockElement(editor)
if (!liveBlock || !editorElement) {
event.preventDefault()
return
}
clearReorderState()
const ownerDocument = event.currentTarget.ownerDocument
const pointerId = event.pointerId
const handlePointerMove = (nativeEvent: PointerEvent) => {
const state = reorderStateRef.current
if (!state || nativeEvent.pointerId !== state.pointerId) return
const distance = Math.hypot(
nativeEvent.clientX - state.startX,
nativeEvent.clientY - state.startY,
)
if (!state.hasMoved && distance < POINTER_REORDER_THRESHOLD_PX) return
state.hasMoved = true
suppressNextClickRef.current = true
state.affordances ??= createReorderAffordances(state)
if (!state.affordances) return
updateDragPreview(state.affordances, nativeEvent.clientX, nativeEvent.clientY)
state.lastDropTarget = validDropTarget({
editor,
state,
x: nativeEvent.clientX,
y: nativeEvent.clientY,
})
updateDropIndicator(state.affordances, state.lastDropTarget ?? null)
nativeEvent.preventDefault()
}
const handlePointerUp = (nativeEvent: PointerEvent) => finishPointerReorder(nativeEvent)
const handlePointerCancel = (nativeEvent: PointerEvent) => {
if (nativeEvent.pointerId !== pointerId) return
clearReorderState()
}
ownerDocument.addEventListener('pointermove', handlePointerMove, true)
ownerDocument.addEventListener('pointerup', handlePointerUp, true)
ownerDocument.addEventListener('pointercancel', handlePointerCancel, true)
reorderStateRef.current = {
clearListeners: () => {
ownerDocument.removeEventListener('pointermove', handlePointerMove, true)
ownerDocument.removeEventListener('pointerup', handlePointerUp, true)
ownerDocument.removeEventListener('pointercancel', handlePointerCancel, true)
},
draggedBlockId: liveBlock.id,
editorElement,
hasMoved: false,
ownerDocument,
pointerId,
startX: event.clientX,
startY: event.clientY,
}
try {
event.currentTarget.setPointerCapture?.(pointerId)
} catch {
// Document-level pointer listeners still complete the reorder gesture.
}
})
}, [block, clearReorderState, editor, finishPointerReorder])
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
if (!suppressNextClickRef.current) return
suppressNextClickRef.current = false
event.preventDefault()
event.stopPropagation()
}, [])
if (!block) return null
return (
<Components.Generic.Menu.Root
onOpenChange={(open: boolean) => {
if (open) sideMenu.freezeMenu()
else sideMenu.unfreezeMenu()
}}
position="left"
>
<Components.Generic.Menu.Trigger>
<span
className="tolaria-block-drag-handle"
onPointerDown={onPointerDown}
onClickCapture={onClickCapture}
>
<Components.SideMenu.Button
label={dict.side_menu.drag_handle_label}
draggable={false}
onDragStart={(event) => event.preventDefault()}
onDragEnd={sideMenu.blockDragEnd}
className="bn-button"
icon={<GripVertical size={20} data-test="dragHandle" />}
/>
</span>
</Components.Generic.Menu.Trigger>
<MenuComponent>{children}</MenuComponent>
</Components.Generic.Menu.Root>
)
}
function TolariaRemoveBlockItem({ children }: { children: ReactNode }) {
const Components = useComponentsContext()!
const { block, editor } = useSideMenuBlock()
if (!block) return null
return (
<Components.Generic.Menu.Item
className="bn-menu-item"
onClick={() => {
runSideMenuAction(() => {
const liveBlock = liveSideMenuBlock(editor, block)
if (!liveBlock) return
editor.removeBlocks([liveBlock.id])
})
}}
>
{children}
</Components.Generic.Menu.Item>
)
}
function TolariaTableHeaderItem({
children,
header,
}: {
children: ReactNode
header: 'column' | 'row'
}) {
const Components = useComponentsContext()!
const { block, editor } = useSideMenuBlock()
const liveBlock = liveSideMenuBlock(editor, block)
const tableContent = tableHeaderContent(liveBlock)
if (!tableContent || !editor.settings.tables.headers) return null
const checked = header === 'row'
? Boolean(tableContent.headerRows)
: Boolean(tableContent.headerCols)
return (
<Components.Generic.Menu.Item
className="bn-menu-item"
checked={checked}
onClick={() => {
runSideMenuAction(() => {
const currentBlock = liveSideMenuBlock(editor, block)
const currentContent = tableHeaderContent(currentBlock)
if (!currentBlock || !currentContent) return
editor.updateBlock(currentBlock.id, {
content: {
...currentContent,
[header === 'row' ? 'headerRows' : 'headerCols']: checked ? undefined : 1,
} as never,
})
})
}}
>
{children}
</Components.Generic.Menu.Item>
)
}
function TolariaDragHandleMenu() {
const dict = useDictionary()
return (
<DragHandleMenu>
<RemoveBlockItem>{dict.drag_handle.delete_menuitem}</RemoveBlockItem>
<TableRowHeaderItem>{dict.drag_handle.header_row_menuitem}</TableRowHeaderItem>
<TableColumnHeaderItem>{dict.drag_handle.header_column_menuitem}</TableColumnHeaderItem>
<TolariaRemoveBlockItem>{dict.drag_handle.delete_menuitem}</TolariaRemoveBlockItem>
<TolariaTableHeaderItem header="row">{dict.drag_handle.header_row_menuitem}</TolariaTableHeaderItem>
<TolariaTableHeaderItem header="column">{dict.drag_handle.header_column_menuitem}</TolariaTableHeaderItem>
</DragHandleMenu>
)
}
export function TolariaSideMenu(props: SideMenuProps) {
return <SideMenu {...props} dragHandleMenu={TolariaDragHandleMenu} />
const { block, editor } = useSideMenuBlock()
useSideMenuTextAlignment(editor, block)
return (
<SideMenu {...props}>
<TolariaAddBlockButton />
<TolariaDragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
</SideMenu>
)
}

View File

@@ -1,3 +1,4 @@
import { Children, isValidElement, type ReactElement } from 'react'
import { describe, expect, it } from 'vitest'
import { getFormattingToolbarItems } from '@blocknote/react'
import {
@@ -44,20 +45,24 @@ describe('tolariaEditorFormatting', () => {
])
})
it('filters unsupported toggle slash-menu variants and annotates supported markdown commands', () => {
it('filters unsupported toggle slash-menu variants and removes command descriptions', () => {
type TolariaSlashMenuTestItem = {
key: string
title: string
onItemClick: () => void
subtext?: string
icon?: ReactElement
}
const items = filterTolariaSlashMenuItems([
{ key: 'toggle_heading', title: 'Toggle heading', onItemClick: () => {} },
{ key: 'toggle_list', title: 'Toggle list', onItemClick: () => {} },
{ key: 'heading', title: 'Heading', onItemClick: () => {} },
{ key: 'bullet_list', title: 'Bullet List', onItemClick: () => {} },
{ key: 'code_block', title: 'Code Block', onItemClick: () => {} },
{ key: 'heading', title: 'Heading', subtext: 'Default heading copy', onItemClick: () => {} },
{ key: 'bullet_list', title: 'Bullet List', subtext: 'Default list copy', onItemClick: () => {} },
{ key: 'code_block', title: 'Code Block', subtext: 'Default code copy', onItemClick: () => {} },
{ key: 'heading_4', title: 'Heading 4', onItemClick: () => {} },
{ key: 'heading_5', title: 'Heading 5', onItemClick: () => {} },
{ key: 'heading_6', title: 'Heading 6', onItemClick: () => {} },
] satisfies TolariaSlashMenuTestItem[])
expect(items.map((item) => item.key)).toEqual([
@@ -65,14 +70,40 @@ describe('tolariaEditorFormatting', () => {
'bullet_list',
'code_block',
])
expect(items.find((item) => item.key === 'heading')?.subtext).toContain(
'Markdown-safe heading',
)
expect(items.find((item) => item.key === 'bullet_list')?.subtext).toContain(
'Markdown-safe bullet list',
)
expect(items.find((item) => item.key === 'code_block')?.subtext).toContain(
'Markdown-safe fenced code block',
)
expect(items.map((item) => item.subtext)).toEqual([
undefined,
undefined,
undefined,
])
})
it('wraps slash-menu icons so hover can swap Phosphor weights', () => {
type TolariaSlashMenuTestItem = {
key: string
title: string
onItemClick: () => void
icon?: ReactElement
}
const items = filterTolariaSlashMenuItems([
{ key: 'heading', title: 'Heading', onItemClick: () => {} },
] satisfies TolariaSlashMenuTestItem[])
const icon = items[0]?.icon
expect(isValidElement(icon)).toBe(true)
if (!isValidElement<{ className?: string; children?: ReactElement[] }>(icon)) return
const iconChildren = Children.toArray(icon.props.children) as Array<
ReactElement<{ className?: string; weight?: string }>
>
expect(icon.props.className).toBe('tolaria-slash-menu-icon')
expect(iconChildren.map((child) => child.props.className)).toEqual([
'tolaria-slash-menu-icon__regular',
'tolaria-slash-menu-icon__fill',
])
expect(iconChildren.map((child) => child.props.weight)).toEqual([
'regular',
'fill',
])
})
})

View File

@@ -3,29 +3,36 @@ import {
getDefaultReactSlashMenuItems,
type DefaultReactSuggestionItem,
} from '@blocknote/react'
import type { ReactElement } from 'react'
import { createElement, type ReactElement } from 'react'
import {
Code2,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
List,
CodeBlock,
File,
ImageSquare,
ListBullets,
ListChecks,
ListOrdered,
Pilcrow,
Quote,
type LucideIcon,
} from 'lucide-react'
ListNumbers,
Minus,
Paragraph,
Quotes,
Smiley,
SpeakerHigh,
Table,
TextHOne,
TextHTwo,
TextHThree,
TextHFour,
TextHFive,
TextHSix,
Video,
type Icon as PhosphorIcon,
} from '@phosphor-icons/react'
type TolariaSlashMenuItem = DefaultReactSuggestionItem & { key: string }
type TolariaBlockTypeSelectItem = {
name: string
type: string
props?: Record<string, boolean | number | string>
icon: LucideIcon
icon: PhosphorIcon
}
const UNSUPPORTED_FORMATTING_TOOLBAR_KEYS = new Set([
@@ -37,6 +44,9 @@ const UNSUPPORTED_FORMATTING_TOOLBAR_KEYS = new Set([
])
const UNSUPPORTED_SLASH_MENU_KEYS = new Set([
'heading_4',
'heading_5',
'heading_6',
'toggle_heading',
'toggle_heading_2',
'toggle_heading_3',
@@ -44,33 +54,60 @@ const UNSUPPORTED_SLASH_MENU_KEYS = new Set([
])
const TOLARIA_BLOCK_TYPE_SELECT_ITEMS: TolariaBlockTypeSelectItem[] = [
{ name: 'Paragraph', type: 'paragraph', icon: Pilcrow },
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: Heading1 },
{ name: 'Heading 2', type: 'heading', props: { level: 2 }, icon: Heading2 },
{ name: 'Heading 3', type: 'heading', props: { level: 3 }, icon: Heading3 },
{ name: 'Heading 4', type: 'heading', props: { level: 4 }, icon: Heading4 },
{ name: 'Heading 5', type: 'heading', props: { level: 5 }, icon: Heading5 },
{ name: 'Heading 6', type: 'heading', props: { level: 6 }, icon: Heading6 },
{ name: 'Quote', type: 'quote', icon: Quote },
{ name: 'Bullet List', type: 'bulletListItem', icon: List },
{ name: 'Numbered List', type: 'numberedListItem', icon: ListOrdered },
{ name: 'Paragraph', type: 'paragraph', icon: Paragraph },
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: TextHOne },
{ name: 'Heading 2', type: 'heading', props: { level: 2 }, icon: TextHTwo },
{ name: 'Heading 3', type: 'heading', props: { level: 3 }, icon: TextHThree },
{ name: 'Heading 4', type: 'heading', props: { level: 4 }, icon: TextHFour },
{ name: 'Heading 5', type: 'heading', props: { level: 5 }, icon: TextHFive },
{ name: 'Heading 6', type: 'heading', props: { level: 6 }, icon: TextHSix },
{ name: 'Quote', type: 'quote', icon: Quotes },
{ name: 'Bullet List', type: 'bulletListItem', icon: ListBullets },
{ name: 'Numbered List', type: 'numberedListItem', icon: ListNumbers },
{ name: 'Checklist', type: 'checkListItem', icon: ListChecks },
{ name: 'Code Block', type: 'codeBlock', icon: Code2 },
{ name: 'Code Block', type: 'codeBlock', icon: CodeBlock },
]
const TOLARIA_SLASH_MENU_SUPPORT_SUBTEXT: Partial<Record<string, string>> = {
heading: 'Markdown-safe heading (`#`). Persists after save and note switches.',
heading_2: 'Markdown-safe heading (`##`). Persists after save and note switches.',
heading_3: 'Markdown-safe heading (`###`). Persists after save and note switches.',
heading_4: 'Markdown-safe heading (`####`). Persists after save and note switches.',
heading_5: 'Markdown-safe heading (`#####`). Persists after save and note switches.',
heading_6: 'Markdown-safe heading (`######`). Persists after save and note switches.',
quote: 'Markdown-safe block quote (`>`). Persists after save and note switches.',
bullet_list: 'Markdown-safe bullet list (`-`). Persists after save and note switches.',
numbered_list: 'Markdown-safe numbered list (`1.`). Persists after save and note switches.',
check_list: 'Markdown-safe checklist (`- [ ]`). Persists after save and note switches.',
paragraph: 'Plain markdown paragraph text. Persists after save and note switches.',
code_block: 'Markdown-safe fenced code block (```...```). Persists after save and note switches.',
const TOLARIA_SLASH_MENU_ICONS: Partial<Record<string, PhosphorIcon>> = {
audio: SpeakerHigh,
bullet_list: ListBullets,
check_list: ListChecks,
code_block: CodeBlock,
divider: Minus,
emoji: Smiley,
file: File,
heading: TextHOne,
heading_2: TextHTwo,
heading_3: TextHThree,
image: ImageSquare,
numbered_list: ListNumbers,
paragraph: Paragraph,
quote: Quotes,
table: Table,
toggle_heading: TextHOne,
toggle_heading_2: TextHTwo,
toggle_heading_3: TextHThree,
toggle_list: ListBullets,
video: Video,
}
function createTolariaSlashMenuIcon(Icon: PhosphorIcon) {
return createElement(
'span',
{ className: 'tolaria-slash-menu-icon' },
createElement(Icon, {
'aria-hidden': true,
className: 'tolaria-slash-menu-icon__regular',
size: 18,
weight: 'regular',
}),
createElement(Icon, {
'aria-hidden': true,
className: 'tolaria-slash-menu-icon__fill',
size: 18,
weight: 'fill',
}),
)
}
export function getTolariaBlockTypeSelectItems() {
@@ -91,11 +128,12 @@ export function filterTolariaSlashMenuItems<T extends TolariaSlashMenuItem>(
return items
.filter((item) => !UNSUPPORTED_SLASH_MENU_KEYS.has(item.key))
.map((item) => {
const tolariaSubtext = TOLARIA_SLASH_MENU_SUPPORT_SUBTEXT[item.key]
if (!tolariaSubtext) return item
const TolariaIcon = TOLARIA_SLASH_MENU_ICONS[item.key]
return {
...item,
subtext: tolariaSubtext,
icon: TolariaIcon ? createTolariaSlashMenuIcon(TolariaIcon) : item.icon,
subtext: undefined,
}
}) as T[]
}

View File

@@ -0,0 +1,131 @@
import { useCallback, useRef, useState } from 'react'
import type { VaultEntry } from '../types'
import type {
ProbeResult,
ProbeRunOptions,
ProbeStep,
ProbeTarget,
ProbeWaiter,
} from './editorMemoryProbeTypes'
import {
copyProbeResult,
DEFAULT_PROBE_BATCH_SIZE,
DEFAULT_PROBE_SETTLE_MS,
loadProbeTarget,
memoryDelta,
readMemorySnapshot,
resolveMountCounts,
selectProbeEntries,
settleAfterMount,
summarizeTarget,
PROBE_READY_TIMEOUT_MS,
} from './editorMemoryProbeRuntime'
function createProbeStep(
mountedTargets: ProbeTarget[],
snapshot: Awaited<ReturnType<typeof readMemorySnapshot>>,
baseline: Awaited<ReturnType<typeof readMemorySnapshot>>,
): ProbeStep {
return {
mountedCount: mountedTargets.length,
mountedPaths: mountedTargets.map(target => target.entry.path),
snapshot,
deltaBytes: memoryDelta(snapshot, baseline),
}
}
function useProbeReadiness() {
const readyPathsRef = useRef(new Set<string>())
const waiterRef = useRef<ProbeWaiter | null>(null)
const resolveWaiterIfReady = useCallback(() => {
const waiter = waiterRef.current
if (!waiter) return
for (const path of waiter.paths) {
if (!readyPathsRef.current.has(path)) return
}
window.clearTimeout(waiter.timer)
waiterRef.current = null
waiter.resolve()
}, [])
const handleReady = useCallback((path: string) => {
readyPathsRef.current.add(path)
resolveWaiterIfReady()
}, [resolveWaiterIfReady])
const waitForReadyPaths = useCallback((paths: string[]) => {
return new Promise<void>((resolve) => {
const unresolvedPaths = paths.filter(path => !readyPathsRef.current.has(path))
if (unresolvedPaths.length === 0) {
resolve()
return
}
const timer = window.setTimeout(() => {
waiterRef.current = null
console.warn('[memory-probe] Timed out waiting for hidden editors:', unresolvedPaths)
resolve()
}, PROBE_READY_TIMEOUT_MS)
waiterRef.current = { paths: new Set(paths), resolve, timer }
})
}, [])
const clearReadiness = useCallback(() => {
if (waiterRef.current) {
window.clearTimeout(waiterRef.current.timer)
waiterRef.current = null
}
readyPathsRef.current.clear()
}, [])
return { clearReadiness, handleReady, waitForReadyPaths }
}
export function useEditorMemoryProbeController(entries: VaultEntry[]) {
const [targets, setTargets] = useState<ProbeTarget[]>([])
const { clearReadiness, handleReady, waitForReadyPaths } = useProbeReadiness()
const clear = useCallback(() => {
clearReadiness()
setTargets([])
}, [clearReadiness])
const run = useCallback(async (options: ProbeRunOptions = {}): Promise<ProbeResult> => {
clear()
await settleAfterMount(options.settleMs ?? DEFAULT_PROBE_SETTLE_MS)
const baseline = await readMemorySnapshot()
const selectedEntries = selectProbeEntries(entries, options)
const loadedTargets = await Promise.all(selectedEntries.map(loadProbeTarget))
const afterContentLoad = await readMemorySnapshot()
const batchSize = Math.max(1, options.batchSize ?? DEFAULT_PROBE_BATCH_SIZE)
const settleMs = options.settleMs ?? DEFAULT_PROBE_SETTLE_MS
const steps: ProbeStep[] = []
for (const count of resolveMountCounts(loadedTargets.length, batchSize)) {
const mountedTargets = loadedTargets.slice(0, count)
setTargets(mountedTargets)
await waitForReadyPaths(mountedTargets.map(target => target.entry.path))
await settleAfterMount(settleMs)
steps.push(createProbeStep(mountedTargets, await readMemorySnapshot(), baseline))
}
return {
targets: loadedTargets.map(summarizeTarget),
baseline,
afterContentLoad,
contentLoadDeltaBytes: memoryDelta(afterContentLoad, baseline),
steps,
}
}, [clear, entries, waitForReadyPaths])
const runAndCopy = useCallback(async (options: ProbeRunOptions = {}) => {
const result = await run(options)
await copyProbeResult(result)
console.info('[memory-probe] Result copied to clipboard', result)
return result
}, [run])
return { clear, handleReady, run, runAndCopy, targets }
}

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