Compare commits
94 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7e33c479d | ||
|
|
69b9da6da6 | ||
|
|
71ef8325a0 | ||
|
|
582d1d25d6 | ||
|
|
ebf4545d46 | ||
|
|
3c483ba0e6 | ||
|
|
71629763ee | ||
|
|
417e37f1d1 | ||
|
|
a3e3192b66 | ||
|
|
86c503c1f9 | ||
|
|
21a47b4a77 | ||
|
|
64d961bd98 | ||
|
|
1b218f5250 | ||
|
|
dfb9f98b7a | ||
|
|
6a033cd2db | ||
|
|
b872b6148c | ||
|
|
c7b6832cb1 | ||
|
|
05a9aacd1e | ||
|
|
701ebe3f07 | ||
|
|
f49b8f60dc | ||
|
|
39615f49f3 | ||
|
|
3d7a0ee936 | ||
|
|
9faee02e04 | ||
|
|
dfff3a848b | ||
|
|
aaf0336733 | ||
|
|
5b906cb3a4 | ||
|
|
0a40f12a6a | ||
|
|
033759598a | ||
|
|
871b62fbd3 | ||
|
|
aa310b25df | ||
|
|
dbb12e96fe | ||
|
|
c795f118d0 | ||
|
|
d96d6efd57 | ||
|
|
67623d848b | ||
|
|
45d72297b3 | ||
|
|
4f86b97e8d | ||
|
|
b6dd6189a2 | ||
|
|
ba07bafc97 | ||
|
|
6d86770abf | ||
|
|
e23ba0ae27 | ||
|
|
7a97f24a0e | ||
|
|
20f34ab9d1 | ||
|
|
19b31cc96a | ||
|
|
e5ed863d5d | ||
|
|
4f7f5a31e4 | ||
|
|
439e2b7f66 | ||
|
|
048d27243d | ||
|
|
58e129cdbc | ||
|
|
713b486750 | ||
|
|
7d66e2f8e1 | ||
|
|
cd6847d59e | ||
|
|
74c9841ad7 | ||
|
|
ba41854f0e | ||
|
|
6532ee125a | ||
|
|
c9698b853e | ||
|
|
db16f2ff5e | ||
|
|
1e5d83d4a3 | ||
|
|
062de2656e | ||
|
|
65d14ebf6f | ||
|
|
0b5149ba8d | ||
|
|
c23a3c1caf | ||
|
|
44e2870697 | ||
|
|
802b6b77d9 | ||
|
|
bb09fcfd6f | ||
|
|
c8ac12f3dc | ||
|
|
fbfda2f15c | ||
|
|
c4c7737e83 | ||
|
|
c24c60b433 | ||
|
|
21532de941 | ||
|
|
f92efd72df | ||
|
|
6e41328f21 | ||
|
|
5b2b4cd569 | ||
|
|
494aeb9533 | ||
|
|
8272e68934 | ||
|
|
7ba20c1e83 | ||
|
|
3384b9acd0 | ||
|
|
8b36ff6b00 | ||
|
|
acb35cb5df | ||
|
|
a8bda0a11b | ||
|
|
4acbe17e1e | ||
|
|
09759d204c | ||
|
|
7a01ef6952 | ||
|
|
1c010ddc22 | ||
|
|
0df5c7882d | ||
|
|
2d138f1564 | ||
|
|
c9ee81c5dc | ||
|
|
04fa74e8d8 | ||
|
|
b42ed35596 | ||
|
|
75e1a82ff0 | ||
|
|
2af2156526 | ||
|
|
568ada8992 | ||
|
|
0987946cff | ||
|
|
81b03f05b6 | ||
|
|
ee71a00c6f |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.91
|
||||
AVERAGE_THRESHOLD=9.92
|
||||
|
||||
137
.github/scripts/prefetch-tauri-nsis.ps1
vendored
Normal file
137
.github/scripts/prefetch-tauri-nsis.ps1
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$nsisUrl = "https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip"
|
||||
$nsisSha1 = "EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D"
|
||||
$tauriUtilsUrl = "https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll"
|
||||
$tauriUtilsSha1 = "75197FEE3C6A814FE035788D1C34EAD39349B860"
|
||||
$tauriUtilsRelativePath = "Plugins\x86-unicode\additional\nsis_tauri_utils.dll"
|
||||
|
||||
$nsisRequiredFiles = @(
|
||||
"makensis.exe",
|
||||
"Bin\makensis.exe",
|
||||
"Stubs\lzma-x86-unicode",
|
||||
"Stubs\lzma_solid-x86-unicode",
|
||||
"Include\MUI2.nsh",
|
||||
"Include\FileFunc.nsh",
|
||||
"Include\x64.nsh",
|
||||
"Include\nsDialogs.nsh",
|
||||
"Include\WinMessages.nsh",
|
||||
"Include\Win\COM.nsh",
|
||||
"Include\Win\Propkey.nsh",
|
||||
"Include\Win\RestartManager.nsh"
|
||||
)
|
||||
|
||||
function Get-UpperSha1 {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
return (Get-FileHash -Algorithm SHA1 -LiteralPath $Path).Hash.ToUpperInvariant()
|
||||
}
|
||||
|
||||
function Test-FileSha1 {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$ExpectedSha1
|
||||
)
|
||||
|
||||
return (Test-Path -LiteralPath $Path) -and ((Get-UpperSha1 -Path $Path) -eq $ExpectedSha1)
|
||||
}
|
||||
|
||||
function Save-VerifiedDownload {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Uri,
|
||||
[Parameter(Mandatory = $true)][string]$OutFile,
|
||||
[Parameter(Mandatory = $true)][string]$ExpectedSha1
|
||||
)
|
||||
|
||||
$parent = Split-Path -Parent $OutFile
|
||||
New-Item -ItemType Directory -Force -Path $parent | Out-Null
|
||||
|
||||
$tempFile = "$OutFile.download"
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
||||
try {
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
|
||||
Invoke-WebRequest -Uri $Uri -OutFile $tempFile -TimeoutSec 120
|
||||
|
||||
$actualSha1 = Get-UpperSha1 -Path $tempFile
|
||||
if ($actualSha1 -ne $ExpectedSha1) {
|
||||
throw "SHA1 mismatch for $Uri. Expected $ExpectedSha1, got $actualSha1."
|
||||
}
|
||||
|
||||
Move-Item -Force -LiteralPath $tempFile -Destination $OutFile
|
||||
return
|
||||
} catch {
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
|
||||
if ($attempt -eq 5) {
|
||||
throw
|
||||
}
|
||||
|
||||
$delaySeconds = [Math]::Min(30, 5 * $attempt)
|
||||
Write-Warning "Download attempt ${attempt} failed: $($_.Exception.Message). Retrying in ${delaySeconds}s."
|
||||
Start-Sleep -Seconds $delaySeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Find-MissingFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Root,
|
||||
[Parameter(Mandatory = $true)][string[]]$RelativePaths
|
||||
)
|
||||
|
||||
foreach ($relativePath in $RelativePaths) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath))) {
|
||||
return $relativePath
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||||
throw "LOCALAPPDATA is required to resolve Tauri's Windows tool cache."
|
||||
}
|
||||
|
||||
$tauriToolsPath = Join-Path $env:LOCALAPPDATA "tauri"
|
||||
$nsisPath = Join-Path $tauriToolsPath "NSIS"
|
||||
$downloadRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
|
||||
[System.IO.Path]::GetTempPath()
|
||||
} else {
|
||||
$env:RUNNER_TEMP
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $tauriToolsPath | Out-Null
|
||||
|
||||
$missingNsisFile = Find-MissingFile -Root $nsisPath -RelativePaths $nsisRequiredFiles
|
||||
if ($missingNsisFile) {
|
||||
Write-Host "Tauri NSIS cache is missing $missingNsisFile; downloading NSIS 3.11."
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $nsisPath
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $tauriToolsPath "nsis-3.11")
|
||||
|
||||
$zipPath = Join-Path $downloadRoot "nsis-3.11.zip"
|
||||
Save-VerifiedDownload -Uri $nsisUrl -OutFile $zipPath -ExpectedSha1 $nsisSha1
|
||||
Expand-Archive -Force -LiteralPath $zipPath -DestinationPath $tauriToolsPath
|
||||
|
||||
$extractedNsisPath = Join-Path $tauriToolsPath "nsis-3.11"
|
||||
if (-not (Test-Path -LiteralPath $extractedNsisPath)) {
|
||||
throw "Downloaded NSIS archive did not contain the expected nsis-3.11 directory."
|
||||
}
|
||||
|
||||
Move-Item -Force -LiteralPath $extractedNsisPath -Destination $nsisPath
|
||||
} else {
|
||||
Write-Host "Tauri NSIS cache already contains NSIS 3.11."
|
||||
}
|
||||
|
||||
$tauriUtilsPath = Join-Path $nsisPath $tauriUtilsRelativePath
|
||||
if (-not (Test-FileSha1 -Path $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1)) {
|
||||
Write-Host "Downloading Tauri NSIS utility plugin."
|
||||
Save-VerifiedDownload -Uri $tauriUtilsUrl -OutFile $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1
|
||||
} else {
|
||||
Write-Host "Tauri NSIS utility plugin is already cached."
|
||||
}
|
||||
|
||||
$missingFile = Find-MissingFile -Root $nsisPath -RelativePaths ($nsisRequiredFiles + @($tauriUtilsRelativePath))
|
||||
if ($missingFile) {
|
||||
throw "Tauri NSIS toolchain is incomplete after prefetch; missing $missingFile."
|
||||
}
|
||||
|
||||
Write-Host "Tauri NSIS toolchain ready at $nsisPath."
|
||||
12
.github/workflows/release-stable.yml
vendored
12
.github/workflows/release-stable.yml
vendored
@@ -401,6 +401,16 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Cache Tauri Windows tools
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~\AppData\Local\tauri
|
||||
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
|
||||
|
||||
- name: Prefetch Tauri NSIS toolchain
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prefetch-tauri-nsis.ps1
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -724,7 +734,7 @@ jobs:
|
||||
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --release-notes-dir release-notes --output-file _site/index.html
|
||||
mkdir -p _site/download
|
||||
cp _site/stable/download/index.html _site/download/index.html
|
||||
|
||||
|
||||
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -455,6 +455,16 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Cache Tauri Windows tools
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~\AppData\Local\tauri
|
||||
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
|
||||
|
||||
- name: Prefetch Tauri NSIS toolchain
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prefetch-tauri-nsis.ps1
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -775,7 +785,7 @@ jobs:
|
||||
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
|
||||
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
|
||||
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
|
||||
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --release-notes-dir release-notes --output-file _site/index.html
|
||||
mkdir -p _site/download
|
||||
cp _site/stable/download/index.html _site/download/index.html
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -73,3 +73,6 @@ CODE-HEALTH-REPORT.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Local Codacy CLI runtime/config generated by the MCP server
|
||||
.codacy/
|
||||
|
||||
@@ -49,7 +49,13 @@
|
||||
"laputa-qa-reference.md",
|
||||
"attachments/laputa-reference.png"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rtl-mixed-direction",
|
||||
"reason": "Arabic and mixed English/Arabic paragraphs keep rich editor and raw editor BiDi QA anchored to the fixture.",
|
||||
"files": [
|
||||
"rtl-mixed-direction-qa.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal file
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: Note
|
||||
topics:
|
||||
- "[[topic-writing]]"
|
||||
---
|
||||
|
||||
# RTL Mixed Direction QA
|
||||
|
||||
مرحبا بالعالم. هذه فقرة عربية لاختبار اتجاه النص من اليمين إلى اليسار داخل محرر تولاريا.
|
||||
|
||||
English text should keep reading left to right when it appears next to Arabic content.
|
||||
|
||||
English then مرحبا بالعالم keeps both scripts readable on one line.
|
||||
|
||||
مرحبا بالعالم then English keeps the Arabic run anchored correctly while preserving the English words.
|
||||
|
||||
Use this note when checking rich editor and raw Markdown editor behavior for automatic LTR/RTL direction.
|
||||
@@ -166,9 +166,13 @@ 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.
|
||||
|
||||
### Table of Contents Outline
|
||||
|
||||
The editor Table of Contents is derived from the live BlockNote document, not from saved Markdown text. `src/utils/tableOfContents.ts` reads structural `heading` blocks with stable ids and levels, extracts inline text from nested BlockNote content, and nests headings by level while preserving document order. `TableOfContentsPanel` receives a document revision from `Editor`, so rich-editor edits refresh the outline immediately without waiting for autosave or a vault reload. Selecting a heading focuses BlockNote and moves the cursor to that block id, while nested headings can be collapsed independently in panel-local UI state.
|
||||
|
||||
### Entity Types (isA / type)
|
||||
|
||||
@@ -200,6 +204,7 @@ Each entity type can have a corresponding **type document**: any markdown note w
|
||||
|
||||
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
|
||||
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
|
||||
- Define instance schema/defaults through ordinary custom frontmatter properties and relationship fields
|
||||
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
|
||||
- Serve as the "definition" for their type category
|
||||
|
||||
@@ -208,7 +213,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 |
|
||||
@@ -218,6 +223,8 @@ Each entity type can have a corresponding **type document**: any markdown note w
|
||||
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
|
||||
|
||||
**Instance schema/defaults**: Custom scalar properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
|
||||
|
||||
**UI behavior**:
|
||||
- Clicking a section group header pins the type document at the top of the NoteList if it exists
|
||||
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
|
||||
@@ -278,6 +285,7 @@ Tolaria separates **display title** from the file identifier:
|
||||
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
|
||||
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
|
||||
- **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`.
|
||||
- **Path identity rules** (`src/utils/notePathIdentity.ts`, `vault/path_identity.rs`): note creation, tab selection, rename bookkeeping, pull refresh, git history, and vault cache updates normalize path separators and macOS `/private/tmp` aliases through one owner. Case folding is reserved for collision/deduplication checks; active-note identity remains case-sensitive.
|
||||
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
|
||||
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while the editor keeps the unsaved buffer intact for another attempt.
|
||||
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
|
||||
@@ -309,6 +317,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 +342,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, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups.
|
||||
|
||||
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
|
||||
|
||||
## File System Integration
|
||||
|
||||
### Vault Scanning (Rust)
|
||||
@@ -356,13 +371,13 @@ All Notes starts from Markdown notes and excludes Markdown files under `attachme
|
||||
|
||||
The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders under the synthetic vault-root row.
|
||||
|
||||
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.
|
||||
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, drains stdout while stdin is still being written, and short-circuits root `.gitignore` detection before walking for nested ignore files, so large ignored folder sets cannot deadlock the native UI while preserving Git semantics as closely as the app can reasonably support.
|
||||
|
||||
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
|
||||
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
|
||||
@@ -398,7 +413,7 @@ The `with_frontmatter()` helper wraps this in a read-transform-write cycle on th
|
||||
|
||||
## Git Integration
|
||||
|
||||
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2).
|
||||
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2). Path-producing commands use `core.quotePath=false` so Unicode note filenames stay as UTF-8 paths across status, history, cache invalidation, and rename detection.
|
||||
|
||||
### Data Types
|
||||
|
||||
@@ -461,7 +476,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 +485,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
|
||||
@@ -525,8 +540,8 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
|
||||
|
||||
- The schema overrides BlockNote's default `codeBlock` spec with `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })` from `@blocknote/code-block`.
|
||||
- Fenced code blocks now use BlockNote's supported Shiki-backed highlighter path, which renders `.shiki` token spans directly inside the editor DOM.
|
||||
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript while still supporting the packaged language aliases such as `ts` → `typescript`.
|
||||
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep BlockNote's dark shell instead of inheriting the muted inline surface.
|
||||
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript at creation time. Parsed unlabeled code blocks then run through Tolaria's lightweight language inference, while explicit fence languages and user dropdown choices still win.
|
||||
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep the dedicated code-block shell instead of inheriting the muted inline surface.
|
||||
|
||||
### Markdown Math
|
||||
|
||||
@@ -539,12 +554,23 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
|
||||
|
||||
### Mermaid Diagrams
|
||||
|
||||
Defined in `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- Fenced `mermaid` blocks become `mermaidBlock` schema nodes before BlockNote sees the Markdown body.
|
||||
- Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG.
|
||||
- The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails.
|
||||
- `serializeMermaidAwareBlocks()` wraps the math-aware serializer so math, wikilinks, and diagrams share the same Markdown-first save path.
|
||||
- `serializeDurableEditorBlocks()` wraps the math-aware serializer so math, wikilinks, Mermaid diagrams, and whiteboards share the same Markdown-first save path.
|
||||
- The `/mermaid` slash command inserts a placeholder rectangle diagram using the same schema-backed Markdown storage path, avoiding an invalid empty diagram state.
|
||||
|
||||
### Tldraw Whiteboards
|
||||
|
||||
Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/tldrawMarkdown.ts`, `src/components/TldrawWhiteboard.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- Fenced `tldraw` blocks become `tldrawBlock` schema nodes before BlockNote sees the Markdown body.
|
||||
- Each `tldrawBlock` stores a stable `boardId` plus the tldraw document snapshot JSON. Session state such as camera, selected tool, and current selection is not persisted into the note.
|
||||
- The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file.
|
||||
- Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner.
|
||||
- The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts.
|
||||
|
||||
### Formatting Surface Policy
|
||||
|
||||
@@ -553,10 +579,13 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
|
||||
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
|
||||
- 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.
|
||||
- `useEditorComposing` tracks editor-owned IME composition events and closes the floating formatting toolbar during composition plus a short post-composition settle window, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
|
||||
- `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 `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. 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. 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 first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, 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. Checklist checkbox handlers also re-resolve the live block before updating `checked`, making delayed clicks after note reloads a no-op instead of a stale block mutation. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
|
||||
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
|
||||
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.
|
||||
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
|
||||
- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor.
|
||||
|
||||
@@ -565,25 +594,25 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
|
||||
B --> C["preProcessMermaidMarkdown(body)\nmermaid fence → token"]
|
||||
B --> C["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences → tokens"]
|
||||
C --> D["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
|
||||
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
F --> G["injectWikilinks + injectMathInBlocks + injectMermaidInBlocks\n tokens → schema nodes"]
|
||||
F --> G["injectWikilinks + injectMathInBlocks + injectDurableEditorMarkdownBlocks\n tokens → schema nodes"]
|
||||
G --> H["editor.replaceBlocks()\n→ rendered editor"]
|
||||
|
||||
style A fill:#f8f9fa,stroke:#6c757d,color:#000
|
||||
style H fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math and Mermaid placeholder tokens use ASCII sentinels with URI-encoded payloads.
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, and tldraw placeholder tokens use ASCII sentinels with URI-encoded payloads.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
|
||||
B --> C["restoreWikilinks + serializeMermaidAwareBlocks()\nschema nodes → Markdown source"]
|
||||
B --> C["restoreWikilinks + serializeDurableEditorBlocks()\nschema nodes → Markdown source"]
|
||||
C --> D["prepend frontmatter yaml"]
|
||||
D --> E["invoke('save_note_content')\n→ disk write"]
|
||||
|
||||
@@ -624,6 +653,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
|
||||
|
||||
@@ -653,10 +683,12 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
|
||||
- **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted.
|
||||
- **Type-derived placeholders**: For typed instances, missing custom properties declared on the type document render as gray editable placeholders. Editing one writes the value to the instance frontmatter; merely displaying it does not backfill the note.
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged.
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged. For typed instances, missing relationship fields declared on the type document render as gray editable placeholders without copying any default relationship targets into existing notes.
|
||||
|
||||
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
||||
|
||||
@@ -740,6 +772,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
|
||||
Tolaria delegates remote auth to the user's system git setup:
|
||||
- `CloneVaultModal` captures a remote URL and local destination
|
||||
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
|
||||
- On Linux AppImage launches, every system-git command removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning `git`, so helpers like `git-remote-https` bind against the host git/library stack instead of Tolaria's bundled WebKit/AppImage libraries
|
||||
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
|
||||
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors, and clone commands fail fast when git wants interactive terminal input
|
||||
- No provider-specific token or username is stored in app settings
|
||||
@@ -763,6 +796,8 @@ interface Settings {
|
||||
ui_language: AppLocale | null
|
||||
note_width_mode: 'normal' | 'wide' | null
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
|
||||
default_ai_target: string | null // "agent:codex" or "model:<provider>/<model>"
|
||||
ai_model_providers: AiModelProvider[] | null
|
||||
hide_gitignored_files: boolean | null // null = default true
|
||||
all_notes_show_pdfs: boolean | null // null = default false
|
||||
all_notes_show_images: boolean | null // null = default false
|
||||
@@ -770,7 +805,7 @@ interface Settings {
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette light/dark actions both update that same value. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -789,6 +824,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
|
||||
### Product Events
|
||||
- **File previews** — `file_preview_opened`, `file_preview_action`, and `file_preview_failed` report only preview/action categories such as `image`, `pdf`, `unsupported`, `open_external`, `copy_path`, and `reveal`.
|
||||
- **Inline image lightbox** — `inline_image_lightbox_opened` records that a rich-editor inline image was opened from double-click, without sending note paths, image URLs, alt text, or file names.
|
||||
- **Code block copy** — `code_block_copied` records that the rich-editor code-block copy action was used, without sending note paths, languages, or code content.
|
||||
- **AI agent sessions** — `ai_agent_message_sent`, `ai_agent_message_blocked`, `ai_agent_response_completed`, `ai_agent_response_failed`, and `ai_agent_permission_mode_changed` use only agent ids, permission modes, counts, and coarse status categories.
|
||||
- **All Notes visibility** — `all_notes_visibility_changed` records only the toggled category and enabled state.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -115,6 +115,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
|
||||
| Editor | BlockNote | 0.46.2 |
|
||||
| Code block highlighting | @blocknote/code-block | 0.46.2 |
|
||||
| Diagram rendering | Mermaid | 11.14.0 |
|
||||
| Whiteboard rendering | tldraw | 4.5.10 |
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
|
||||
| UI primitives | Radix UI + shadcn/ui | - |
|
||||
@@ -141,7 +142,7 @@ flowchart TD
|
||||
SB["Sidebar\n(navigation + filters + types)"]
|
||||
NL["NoteList / PulseView\n(filtered list / activity)"]
|
||||
ED["Editor\n(BlockNote + diff + raw)"]
|
||||
IN["Inspector\n(metadata + relationships)"]
|
||||
IN["Right Panel\n(Inspector + TOC)"]
|
||||
AIP["AiPanel\n(selected CLI agent + tools)"]
|
||||
SP["SearchPanel\n(keyword search)"]
|
||||
ST["StatusBar\n(vault picker + sync + version)"]
|
||||
@@ -184,10 +185,10 @@ flowchart TD
|
||||
|
||||
```
|
||||
┌────────┬─────────────┬─────────────────────────┬────────────┐
|
||||
│Sidebar │ Note List │ Editor │ Inspector │
|
||||
│Sidebar │ Note List │ Editor │ Right Panel│
|
||||
│(250px) │ (300px) │ (flex-1) │ (280px) │
|
||||
│ │ OR │ │ OR │
|
||||
│ All │ Pulse View │ [Breadcrumb Bar] │ AI Chat │
|
||||
│ All │ Pulse View │ [Breadcrumb Bar] │ TOC │
|
||||
│ Changes│ │ │ OR │
|
||||
│ Pulse │ [Search] │ # My Note │ AI Agent │
|
||||
│ Inbox │ [Sort/Filt] │ │ │
|
||||
@@ -205,8 +206,8 @@ flowchart TD
|
||||
|
||||
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
|
||||
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.
|
||||
|
||||
@@ -214,10 +215,10 @@ The main Tauri window derives its minimum width from the visible panes instead o
|
||||
|
||||
The main Tauri window also persists its last normal size and screen position in the app config directory as `window-state.json`. The state stores logical window points, while `window_state.rs` migrates older physical-pixel state on read so Retina and non-Retina launches restore the same user-facing bounds. On startup, the restored frame applies only to the main window and clamps to the currently available monitor work areas, so stale coordinates from a disconnected display fall back to a visible placement. Maximized, fullscreen, minimized, and detached note-window frames are not written as the restore baseline.
|
||||
|
||||
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge.
|
||||
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
|
||||
|
||||
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, while cross-platform custom items such as Check for Updates emit Tolaria command IDs and show visible updater feedback.
|
||||
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first available system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, while the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.
|
||||
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback.
|
||||
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
@@ -243,13 +244,13 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts` + `aiTargets.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-target selection, and the per-vault Safe / Power User permission mode shown in the panel header for coding agents
|
||||
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
|
||||
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`
|
||||
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. Codex, 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 using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, 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 expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
|
||||
|
||||
#### Agent Event Flow
|
||||
|
||||
@@ -298,19 +299,25 @@ The agent panel (`ai-context.ts`) builds a structured JSON snapshot from the act
|
||||
|
||||
```json
|
||||
{
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "content" },
|
||||
"linkedNotes": [{ "path", "title", "content" }],
|
||||
"openTabs": [{ "title", "snippet" }],
|
||||
"vaultMetadata": { "noteTypes", "stats", "filter" },
|
||||
"references": [{ "title", "path", "type" }]
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "body", "wordCount", "bodyTruncated?" },
|
||||
"openTabs": [{ "path", "title", "type", "frontmatter" }],
|
||||
"noteList": [{ "path", "title", "type" }],
|
||||
"vault": { "types", "totalNotes" },
|
||||
"referencedNotes": [{ "title", "path", "type" }]
|
||||
}
|
||||
```
|
||||
|
||||
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
|
||||
Large active notes are compacted into a head/tail body snapshot before they enter the CLI prompt. The snapshot records `bodyTruncated` metadata and instructs agents to call `get_note(path)` before content-sensitive edits or summaries, keeping lower-context OpenCode providers from failing on oversized active-note context while preserving access to the full note through MCP.
|
||||
|
||||
### Direct Model Targets
|
||||
|
||||
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
|
||||
|
||||
Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Local endpoints can omit authentication.
|
||||
|
||||
### Authentication
|
||||
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings.
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables.
|
||||
|
||||
## MCP Server
|
||||
|
||||
@@ -350,7 +357,7 @@ Tolaria can register itself as an MCP server in:
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria` and `/usr/lib/tolaria`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -421,7 +428,7 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
|
||||
|
||||
### Cache File
|
||||
|
||||
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
|
||||
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
|
||||
|
||||
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
|
||||
|
||||
@@ -490,6 +497,8 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it
|
||||
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
|
||||
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
|
||||
|
||||
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
|
||||
|
||||
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
|
||||
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
@@ -510,8 +519,9 @@ Tolaria no longer implements provider-specific OAuth or remote-repository APIs.
|
||||
1. User opens `CloneVaultModal` from onboarding or the vault menu
|
||||
2. User pastes any git URL and chooses a local destination
|
||||
3. The `clone_git_repo()` Tauri command runs `git clone` inside a blocking Tokio task so the Tauri window stays responsive during slow or failing clones
|
||||
4. `git_push()` / `git_pull()` continue to use the same system git path
|
||||
5. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input
|
||||
4. Linux AppImage builds strip AppImage loader variables from system-git subprocesses before spawning `git`, keeping `git-remote-https` on the host git/library stack
|
||||
5. `git_push()` / `git_pull()` continue to use the same system git path
|
||||
6. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input
|
||||
|
||||
**Auth model:**
|
||||
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
|
||||
@@ -555,6 +565,11 @@ sequenceDiagram
|
||||
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
|
||||
T-->>VL: VaultEntry[]
|
||||
VL->>T: invoke('get_modified_files')
|
||||
alt Runtime vault path disappears
|
||||
VL->>T: invoke('check_vault_exists')
|
||||
VL-->>A: unavailable vault path + cleared stale state
|
||||
A-->>U: WelcomeScreen (vault missing)
|
||||
end
|
||||
A->>T: useMcpStatus — check explicit MCP setup state
|
||||
A->>T: sync_mcp_bridge_vault(selected path)
|
||||
VL-->>A: entries ready
|
||||
@@ -564,9 +579,10 @@ sequenceDiagram
|
||||
A->>T: invoke('get_note_content')
|
||||
T-->>A: raw markdown
|
||||
A->>A: splitFrontmatter → [yaml, body]
|
||||
A->>A: preProcessDurableEditorMarkdown(body)
|
||||
A->>A: preProcessWikilinks(body)
|
||||
A->>A: tryParseMarkdownToBlocks()
|
||||
A->>A: injectWikilinks(blocks)
|
||||
A->>A: injectWikilinks + injectDurableEditorMarkdownBlocks(blocks)
|
||||
A-->>U: Editor renders note
|
||||
```
|
||||
|
||||
@@ -643,7 +659,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
|
||||
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `ignored.rs` | Gitignored-content visibility filtering via batched `git check-ignore` |
|
||||
| `ignored.rs` | Gitignored-content visibility filtering via batched, pipe-safe `git check-ignore` |
|
||||
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
|
||||
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
|
||||
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
|
||||
@@ -683,6 +699,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `rename_note` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts |
|
||||
| `move_note_to_folder` | Crash-safe folder move that preserves the filename, reloads the moved note, and rewrites path-based wikilinks |
|
||||
| `create_vault_folder` | Create a folder relative to the active vault root |
|
||||
| `list_vault_folders` | Build the folder tree on the blocking Tokio pool, then apply Gitignored-content visibility → `Vec<FolderNode>` |
|
||||
| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths |
|
||||
| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root |
|
||||
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
|
||||
@@ -754,6 +771,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
|
||||
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
|
||||
| `copy_text_to_clipboard` | Copy setup snippets through the native desktop clipboard command path |
|
||||
| `read_text_from_clipboard` | Read current desktop clipboard text for command-driven plain-text paste |
|
||||
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
|
||||
|
||||
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
|
||||
@@ -820,7 +838,7 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
|
||||
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
| `appCommandDispatcher` | Manifest-backed shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
|
||||
|
||||
@@ -834,6 +852,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+S | Save current note |
|
||||
| Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it |
|
||||
| Cmd+Shift+F | Find in vault |
|
||||
| Cmd+Shift+V | Paste without Formatting into the active supported editing surface |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) |
|
||||
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
|
||||
| Cmd+1–9 | Switch to tab N |
|
||||
@@ -844,12 +863,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; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow`
|
||||
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
|
||||
- Deterministic QA uses two explicit proof paths from the shared manifest:
|
||||
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
|
||||
|
||||
@@ -37,13 +37,13 @@ On some Wayland systems, the Linux AppImage may fail to launch with:
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with the system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
|
||||
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib/libwayland-client.so ./Tolaria*.AppImage
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib64/libwayland-client.so.0 ./Tolaria*.AppImage
|
||||
```
|
||||
|
||||
If your distribution stores the library elsewhere, use that path instead, for example `/usr/lib64/libwayland-client.so.0` or `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`.
|
||||
If your distribution stores the 64-bit library elsewhere, use that path instead, for example `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`. On 64-bit Fedora, avoid `/usr/lib/libwayland-client.so.0`; that path can point at a 32-bit library and be ignored by the loader with a wrong ELF class warning.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -69,6 +69,8 @@ pnpm playwright:regression # Full Playwright regression suite
|
||||
|
||||
`create_getting_started_vault` clones the public starter repo and then removes every git remote from the new local copy. That means Getting Started vaults open local-only by default. Users connect a compatible remote later through the bottom-bar `No remote` chip or the command palette, both of which feed the same `AddRemoteModal` and `git_add_remote` backend flow.
|
||||
|
||||
Linux AppImage builds still use the user's system `git`. Before Tolaria spawns that `git` process, it removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` so HTTPS clone helpers use the host git libraries instead of bundled AppImage libraries.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
@@ -157,6 +159,7 @@ tolaria/
|
||||
│ ├── utils/ # Pure utility functions (~48 files)
|
||||
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML parser
|
||||
│ │ ├── plainTextPaste.ts # Shared Paste without Formatting command target registry
|
||||
│ │ ├── platform.ts # Runtime platform + Linux chrome gating helpers
|
||||
│ │ ├── ai-agent.ts # Agent stream utilities
|
||||
│ │ ├── ai-chat.ts # Token estimation utilities
|
||||
@@ -361,7 +364,7 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
@@ -406,7 +409,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.)
|
||||
2. Add a command handler in `commands/`
|
||||
3. Register it in the `generate_handler![]` macro in `lib.rs`
|
||||
4. Call it from the frontend via `invoke()` in the appropriate hook
|
||||
4. Call it from the frontend via `invoke()` in the appropriate hook or utility, keeping native-only permission work behind the Tauri command boundary
|
||||
5. Add a mock handler in `mock-tauri.ts`
|
||||
|
||||
### Add a new component
|
||||
@@ -446,7 +449,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
### Work with external MCP setup
|
||||
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`), and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
|
||||
|
||||
48
docs/adr/0104-tauri-frontend-readiness-watchdog.md
Normal file
48
docs/adr/0104-tauri-frontend-readiness-watchdog.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0104"
|
||||
title: "Tauri frontend readiness watchdog"
|
||||
status: active
|
||||
date: 2026-05-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already keeps heavy filesystem and subprocess work off the Tauri window-creation path, but that alone does not protect against a different startup failure mode: the desktop WebView can render the static HTML shell while the React app never becomes interactive.
|
||||
|
||||
On macOS this showed up as an inert window that looked launched but never finished mounting the real app. The failure boundary is cross-layer:
|
||||
|
||||
- `index.html` can paint before React commits
|
||||
- React root errors can happen before the app reports itself ready
|
||||
- a plain reload is acceptable as a one-time recovery, but an automatic reload loop is not
|
||||
- browser/mock runs should not inherit desktop-only recovery behavior
|
||||
|
||||
Tolaria needs a startup contract that distinguishes “HTML painted” from “frontend actually became interactive”, and a bounded recovery path when that contract is not satisfied.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses a Tauri-only frontend-readiness watchdog that reloads the WebView at most once if React never reports startup readiness.**
|
||||
|
||||
Concretely:
|
||||
|
||||
- `index.html` installs a Tauri-only startup timer before React loads
|
||||
- React dispatches a readiness signal from a mounted effect after the app shell commits
|
||||
- if readiness never arrives before the timeout, the WebView reloads once
|
||||
- the same one-shot reload path is available to React root error handling before readiness is marked
|
||||
- `sessionStorage` tracks whether the startup reload was already attempted so Tolaria does not loop forever
|
||||
- browser/mock environments keep using ordinary browser clipboard/storage behavior and do not enable this desktop startup recovery path
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Tauri-only readiness watchdog with one-shot reload** (chosen): directly addresses the inert-startup failure mode, keeps recovery local to the frontend, and avoids permanent reload loops. Cost: startup now depends on a small cross-layer contract between HTML bootstrap and React.
|
||||
- **Do nothing and rely on manual relaunch**: simplest implementation, but leaves users stranded in a broken-looking app state with no automatic recovery.
|
||||
- **Reload on any React root error without a readiness gate**: more aggressive, but too noisy; post-startup runtime errors should not trigger surprise reloads.
|
||||
- **Move recovery entirely into native Rust window/bootstrap logic**: possible, but the failure signal lives in the frontend lifecycle, so native code would still need a readiness handshake.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tolaria now distinguishes successful frontend startup from merely rendering the HTML shell.
|
||||
- Desktop startup recovery is bounded to a single retry per session, reducing the chance of trapping users in reload loops.
|
||||
- `index.html`, `src/main.tsx`, and `src/utils/frontendReady.ts` form a shared startup contract that future bootstrap refactors must preserve.
|
||||
- Any future change that delays app-shell mount beyond the watchdog timeout must re-evaluate the timeout and readiness trigger.
|
||||
- If a startup failure persists after one retry, Tolaria still surfaces the broken state instead of hiding a deeper bug behind repeated reloads.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0105"
|
||||
title: "Editor correctness and responsiveness contract"
|
||||
status: active
|
||||
date: 2026-05-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are durable Markdown files on disk, but the rich editor renders them through BlockNote blocks. Large notes exposed a tempting optimization: show a fast Markdown preview first, then hydrate BlockNote later. In practice, that creates two renderers for the same document, visible flicker, delayed click-time lag, and more places for stale async work to race with the currently selected note.
|
||||
|
||||
The editor must optimize for the product priorities in this order:
|
||||
|
||||
1. no crashes
|
||||
2. no stale content, race-condition overwrites, or lost edits
|
||||
3. responsive typing and cursor movement
|
||||
4. fast note-list-to-editor loading
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps a single direct editor surface for Markdown notes and treats editor content swaps as generation-checked, source-content-checked operations.** Fast loading may use raw file-content prefetching and a bounded parsed-block cache, but it must not show a separate preview that later swaps into the editor. Parsed BlockNote blocks are reusable only when their source Markdown exactly matches the content being opened, and background parsing must run only after recent typing/navigation has gone idle.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Single direct editor surface with guarded swaps plus bounded caches** (chosen): preserves one visual representation of the document, rejects stale async parse results, validates or identity-checks cached disk content before opening, and keeps typing work debounced. Cons: very large notes can still wait on BlockNote conversion when they were not warmed.
|
||||
- **Fast Markdown preview followed by hidden BlockNote hydration**: improves first paint but creates flicker, delayed edit-time stalls, and duplicated rendering semantics.
|
||||
- **Unbounded or eager background BlockNote parsing for likely next notes**: can make some opens faster, but competes with typing/navigation and introduces stale parse-result hazards unless heavily scheduled, bounded, and invalidated.
|
||||
- **Always raw mode for large notes**: strongest responsiveness for huge files, but changes the editing experience abruptly and should be an explicit fallback rather than the default.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Async editor work must prove it still matches both the latest swap generation and the latest tab source content before touching BlockNote.
|
||||
- Cached raw note content must be validated against disk before it is shown unless it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`, or the content was just authored by Tolaria in the current process.
|
||||
- Cached parsed BlockNote blocks must be keyed by vault, path, and exact source content, cloned on read/write, and bounded by entry count plus source byte budget.
|
||||
- Background parsed-block warming is allowed only for likely next large Markdown notes after a foreground idle window; active typing, raw mode, and editor mount state must defer it.
|
||||
- Dirty local editor content remains authoritative. External filesystem refreshes may replace clean notes, but must not overwrite unsaved local edits.
|
||||
- Per-keystroke editor work must stay minimal. Serialization, metadata derivation, autosave, and cache updates should be debounced, coalesced, or scheduled away from active typing.
|
||||
- Future large-note optimizations should target true progressive/chunked conversion or explicit raw/read-only fallback states, not a visually different preview that morphs into BlockNote.
|
||||
31
docs/adr/0106-shared-app-command-manifest.md
Normal file
31
docs/adr/0106-shared-app-command-manifest.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0106"
|
||||
title: "Shared app command manifest"
|
||||
status: active
|
||||
date: 2026-05-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria command metadata was split across several runtime surfaces: TypeScript owned shortcut lookup and command-palette shortcut display, Rust owned native menu IDs, labels, accelerators, aliases, and enablement groups, and the Linux titlebar fallback menu duplicated another command list. Adding or changing a command required carefully editing multiple files that could drift while still compiling.
|
||||
|
||||
The existing renderer-first shortcut model and native-menu dedupe remain correct, but they need a single source for metadata that must be identical across those surfaces.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria stores cross-runtime app command metadata in `src/shared/appCommandManifest.json`, and both the renderer and Tauri native menu derive their command/menu IDs, accelerators, menu labels, menu aliases, enablement groups, and deterministic QA metadata from it.** Context-sensitive command-palette builders still own availability and execution callbacks, and OS-native menu entries remain local to the native menu implementation.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Shared JSON manifest included by TypeScript and Rust** (chosen): works in both runtimes without code generation, keeps menu metadata reviewable, and lets tests validate drift directly.
|
||||
- **Generate TypeScript and Rust constants from a schema**: gives stronger compile-time types but adds a build step and a generated-file maintenance burden for a small manifest.
|
||||
- **Keep duplicated constants with more tests**: reduces immediate refactor scope, but still forces every command change through parallel manual edits.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New app commands that appear in native menus or shortcut QA must be added to `src/shared/appCommandManifest.json`.
|
||||
- `appCommandCatalog.ts` is responsible for turning the manifest into typed renderer helpers such as `APP_COMMAND_IDS`, shortcut lookup maps, Linux menu sections, and deterministic QA definitions.
|
||||
- `src-tauri/src/menu.rs` includes the same manifest JSON, builds custom menu items from it, maps overridden menu item IDs such as `file-quick-open-alias` back to their primary command IDs, and resolves state-dependent enablement groups from manifest entries.
|
||||
- Platform-native menu items such as Undo, Redo, Copy, Paste, Select All, Services, Quit, and Window controls stay in Rust because they are OS affordances, not Tolaria app commands.
|
||||
- Command-palette builders continue to own dynamic labels, filtering, enabled state, and callbacks where those depend on current app state.
|
||||
43
docs/adr/0107-markdown-durable-tldraw-whiteboards.md
Normal file
43
docs/adr/0107-markdown-durable-tldraw-whiteboards.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0107"
|
||||
title: "Markdown-durable tldraw whiteboards in notes"
|
||||
status: active
|
||||
date: 2026-05-03
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are durable Markdown files, while whiteboard editing needs an interactive canvas with structured shape data. tldraw provides a mature React whiteboard runtime and exposes snapshot APIs that can persist the document without using browser-local `persistenceKey` storage.
|
||||
|
||||
The storage decision needs to preserve Tolaria's filesystem source-of-truth rule: deleting local caches must not lose a board, raw mode must expose the canonical source, and Git should track the board together with the surrounding note.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support whiteboards as Markdown-durable fenced `tldraw` blocks backed by tldraw document snapshots.**
|
||||
|
||||
The implementation:
|
||||
|
||||
- Converts fenced `tldraw` blocks into temporary placeholders before BlockNote parses Markdown.
|
||||
- Replaces those placeholders with `tldrawBlock` schema blocks that store a stable board id and tldraw document snapshot JSON.
|
||||
- Renders each block with the `tldraw` package inside the rich editor.
|
||||
- Debounces tldraw document changes into BlockNote block props so Tolaria's normal autosave writes the snapshot into the `.md` file.
|
||||
- Serializes `tldrawBlock` nodes back to fenced Markdown before save, raw-mode entry, and editor-position snapshots.
|
||||
- Adds a `/whiteboard` slash command that inserts the same block format.
|
||||
|
||||
Session state such as camera position, selected shapes, and selected tools is not persisted into the note. Preview images are intentionally omitted from the initial design; they may be introduced later as derived cache artifacts for note lists, search results, or graph cards.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Markdown fenced block with tldraw document snapshot** (chosen): keeps the board in the note file, matches Tolaria's Mermaid/math round-trip model, and works for both full whiteboard notes and embedded boards inside larger notes.
|
||||
- **tldraw `persistenceKey` / IndexedDB**: simplest app integration, but violates the vault-as-source-of-truth rule and would lose boards when browser storage is cleared.
|
||||
- **Separate `.tldr` files embedded from Markdown**: keeps JSON out of prose notes, but fragments note ownership, makes embedded boards harder to move with their parent note, and complicates Git history for small board edits.
|
||||
- **Preview-image-first storage**: useful for thumbnails, but not editable source. It would make the PNG an attractive but stale source of truth.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/utils/tldrawMarkdown.ts` is the canonical parser/serializer bridge for whiteboard blocks.
|
||||
- `src/components/TldrawWhiteboard.tsx` owns the tldraw runtime integration and only persists document snapshots.
|
||||
- Raw mode remains a direct source editor for the fenced JSON.
|
||||
- Embedded whiteboards and future full-note whiteboard templates share the same storage format.
|
||||
- Asset support is deferred; when added, asset bytes should live in vault-relative attachment paths referenced from the tldraw asset records.
|
||||
32
docs/adr/0107-pointer-owned-editor-block-reordering.md
Normal file
32
docs/adr/0107-pointer-owned-editor-block-reordering.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0107"
|
||||
title: "Pointer-owned editor block reordering"
|
||||
status: active
|
||||
date: 2026-05-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria uses BlockNote for rich Markdown editing, Tauri for native desktop file drops, and custom editor drop handlers for images and wikilinks. BlockNote's default block drag path depends on HTML5 drag events and `DataTransfer`. On macOS inside the Tauri webview, that makes block reordering fragile because the same browser-level drag system also carries native file/image drops into the editor.
|
||||
|
||||
Regressions tended to oscillate: fixes that restored block dragging could break image drops, and fixes that protected native drops could make block reordering fail or lose visual feedback. The drag handle also needs editor-specific affordances: a moving block preview, an insertion separator, stale-block protection, and typography-aware positioning for the side-menu controls.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria owns editor block reordering as a pointer gesture that directly moves BlockNote blocks, and leaves HTML5/native drag data paths for file, image, and external drops.** The drag side menu is responsible for resolving live BlockNote blocks, computing pointer hit targets, rendering drag affordances, and aligning the hover controls to the rendered text range of the hovered block.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Pointer-owned block reordering** (chosen): separates internal block moves from native drop payloads, works without `DataTransfer`, gives Tolaria deterministic visual affordances, and can be tested with Playwright pointer/mouse actions. Cons: Tolaria now owns a small amount of hit-testing, block-move, and affordance code around BlockNote.
|
||||
- **Continue using BlockNote's HTML5 drag handler**: keeps less local code, but ties internal block moves to the same unstable drag channel used by native file drops in Tauri.
|
||||
- **Patch native file drops around BlockNote drag events**: might repair individual regressions, but preserves the root coupling between editor-internal reordering and external drag payload handling.
|
||||
- **Disable block dragging on macOS/Tauri**: avoids the conflict, but removes an important editing workflow.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Internal block reordering must not depend on `DataTransfer` or `draggable=true`.
|
||||
- File/image/wikilink drop behavior remains owned by the existing editor drop abstractions and native Tauri file-drop bridge.
|
||||
- Block reordering tests should use pointer or mouse movement in Playwright, and should assert the moving preview and insertion separator while the drag is in progress.
|
||||
- The side menu should align to measured rendered content rather than heading-specific pixel offsets, so future theme font-size and line-height changes do not need drag-control retuning.
|
||||
- Changes to BlockNote DOM structure around `.bn-block-content`, `.bn-inline-content`, `.bn-side-menu`, or block container IDs require rechecking the pointer hit-testing and side-menu alignment tests.
|
||||
30
docs/adr/0108-direct-model-ai-targets.md
Normal file
30
docs/adr/0108-direct-model-ai-targets.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0108"
|
||||
title: "Direct model AI targets alongside coding agents"
|
||||
status: active
|
||||
date: 2026-05-03
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's AI panel originally targeted desktop coding-agent CLIs only. That works well for tool-capable vault editing, but it excludes users who run local model servers, users who prefer OpenAI or Anthropic APIs, and future mobile builds where desktop subprocesses cannot run.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria models AI selection as an AI target.** Targets can be desktop coding agents or direct model endpoints. Coding agents keep the existing Safe / Power User permission modes and tool access. Direct model targets run in Chat mode: they receive note context and conversation history, but they do not get vault-write tools or shell access.
|
||||
|
||||
Direct model provider metadata is stored in app settings. Provider API secrets are not stored in settings; hosted providers can either save a key in Tolaria's local app-data secrets file or read a key from a named environment variable. The local secrets file is outside the vault, outside project worktrees, and written with owner-only file permissions on Unix platforms. Local providers such as Ollama and LM Studio can run without a key.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **AI target abstraction** (chosen): supports agents, local models, hosted APIs, and mobile-compatible model runtimes without pretending all AI backends have the same capabilities.
|
||||
- **Treat custom providers as OpenCode configuration only**: lower implementation cost on desktop, but does not help mobile and keeps API users dependent on a coding-agent install.
|
||||
- **Direct API runtime with write tools immediately**: powerful, but would require Tolaria-owned tool loops, confirmations, retries, and safety semantics before the basic chat value is proven.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Settings owns durable provider setup and default target selection.
|
||||
- The status bar becomes a quick target switcher across agents and configured model targets.
|
||||
- The AI panel explains capability differences: agents show Safe / Power User; direct model targets show Chat mode.
|
||||
- Future work can migrate local secrets to OS keychain storage and add read/write tool loops without changing the top-level target model.
|
||||
25
docs/adr/0108-sanitized-rendered-markup-and-safe-regex.md
Normal file
25
docs/adr/0108-sanitized-rendered-markup-and-safe-regex.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 0108. Sanitized Rendered Markup and Safe User Regex
|
||||
|
||||
Date: 2026-05-03
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria renders generated SVG/HTML from trusted libraries such as Mermaid and KaTeX, and it allows users to opt into regex matching in filters and editor find/replace. Codacy SRM flagged the raw markup insertion and direct regex construction as Critical XSS/DoS risks.
|
||||
|
||||
## Decision
|
||||
|
||||
Add direct runtime dependencies on `dompurify` and `safe-regex2`.
|
||||
|
||||
Rendered Mermaid SVG and KaTeX HTML must be sanitized before insertion and mounted through DOM nodes rather than React `dangerouslySetInnerHTML`. User-provided regex sources must be length-bounded and checked with `safe-regex2` before compilation.
|
||||
|
||||
SCA-reported vulnerable transitive dependencies are pinned through package-manager overrides so Codacy resolves patched floors until upstream dependencies adopt them naturally. This includes `protobufjs`, the MCP SDK web-server stack, and Vite's parser/build transitive stack. Rust lockfile-only updates keep OpenSSL, rustls-webpki, and tar on patched versions without changing public Tauri APIs.
|
||||
|
||||
## Consequences
|
||||
|
||||
Markup rendering now has an explicit sanitizer boundary that is shared by Mermaid and math rendering. User regex features remain available, but unsafe or overly large expressions fail validation instead of running in the UI thread.
|
||||
|
||||
The overrides should be removed once the dependency graph no longer pulls the vulnerable versions.
|
||||
32
docs/adr/0109-debounced-worker-derived-editor-indexes.md
Normal file
32
docs/adr/0109-debounced-worker-derived-editor-indexes.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0109"
|
||||
title: "Debounced worker-derived editor indexes"
|
||||
status: active
|
||||
date: 2026-05-04
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Right side panels can need derived indexes of the active note, such as the Table of Contents hierarchy. These indexes are useful while editing, but rebuilding them synchronously during note opening or on every keystroke competes with the editor's main-thread work and violates ADR-0105's responsiveness contract.
|
||||
|
||||
The Table of Contents also needs live BlockNote block IDs for navigation, while the fastest and most stable source for the outline itself is the active note's Markdown content. Binding the outline rebuild directly to BlockNote document mutations makes typing and note swaps more expensive than necessary.
|
||||
|
||||
## Decision
|
||||
|
||||
**Derived editor indexes that are not required for the editor surface itself must be lazy, debounced, and built off the main thread when they can be derived from Markdown.** The Table of Contents does not build while its panel is closed; once opened, it uses a Web Worker to build its Markdown-derived H1/H2/H3 tree after a debounce, while live BlockNote block IDs are resolved only at click time for navigation.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Lazy debounced Web Worker for Markdown-derived indexes** (chosen): avoids any TOC work while the panel is closed, keeps outline parsing away from typing and note-opening work once opened, cancels stale panel updates, and lets the rendered editor remain the only editor surface. Cons: adds a small worker/client path and a title-only interim state.
|
||||
- **Main-thread deferred rebuild with `setTimeout`**: avoids blocking the first render, but still runs on the UI thread and can still rebuild too often during active edits.
|
||||
- **Synchronous rebuild from the BlockNote document**: simplest and gives immediate block IDs, but makes every BlockNote document update a potential side-panel rebuild.
|
||||
- **Never update the TOC while editing**: safest for typing performance, but stale outlines make the panel misleading for active authoring.
|
||||
|
||||
## Consequences
|
||||
|
||||
- TOC tree state is driven by note identity plus debounced Markdown content, not by BlockNote document churn.
|
||||
- Closing the TOC panel unmounts the panel and cancels pending debounce callbacks; no worker request is scheduled while the panel is closed.
|
||||
- The TOC may briefly show only the note title after a note switch or edit burst; the full tree appears when the debounced worker result returns.
|
||||
- Navigation remains tied to the live editor: block IDs are resolved from the current BlockNote document at click time and scrolled/focused then.
|
||||
- Future derived side-panel indexes should follow the same pattern when they parse or scan note content and are not needed to render the editor itself.
|
||||
@@ -156,3 +156,10 @@ 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-markdown-durable-tldraw-whiteboards.md) | Markdown-durable tldraw whiteboards in notes | active |
|
||||
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |
|
||||
| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active |
|
||||
| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active |
|
||||
|
||||
44
index.html
44
index.html
@@ -50,6 +50,50 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var readyEventName = 'tolaria:frontend-ready';
|
||||
var reloadAttemptKey = 'tolaria:startup-reload-attempted';
|
||||
var startupTimeoutMs = 10000;
|
||||
var isTauri = '__TAURI__' in window || '__TAURI_INTERNALS__' in window;
|
||||
|
||||
if (!isTauri) return;
|
||||
|
||||
function hasReloadAttempted() {
|
||||
try {
|
||||
return sessionStorage.getItem(reloadAttemptKey) === '1';
|
||||
} catch (err) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function markReloadAttempted() {
|
||||
try {
|
||||
sessionStorage.setItem(reloadAttemptKey, '1');
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearReloadAttempt() {
|
||||
try {
|
||||
sessionStorage.removeItem(reloadAttemptKey);
|
||||
} catch (err) {
|
||||
// Storage can be unavailable in hardened WebView/privacy modes.
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(readyEventName, clearReloadAttempt, { once: true });
|
||||
window.setTimeout(function () {
|
||||
if (window.__tolariaFrontendReady === true) return;
|
||||
if (hasReloadAttempted()) return;
|
||||
if (!markReloadAttempted()) return;
|
||||
|
||||
window.location.reload();
|
||||
}, startupTimeoutMs);
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
94
lara.lock
94
lara.lock
@@ -11,7 +11,7 @@ files:
|
||||
command.openSettings: 638c05f4f159a403e04aebe94b46a2a8
|
||||
command.openSettings.keywords: ab6b5e03395f4db955ed9cbfd4de33b7
|
||||
command.openLanguageSettings: e5a425aa6222fab3df66c0e1e0ca4183
|
||||
command.openLanguageSettings.keywords: 639afefdd1c238b381bc1f963288f389
|
||||
command.openLanguageSettings.keywords: 6a910000cbf23eac3c7078e518cae66a
|
||||
command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca
|
||||
command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf
|
||||
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
|
||||
@@ -39,6 +39,7 @@ files:
|
||||
command.note.newType: adce5108f18945cc502a06c02445e32d
|
||||
command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2
|
||||
command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047
|
||||
command.note.pastePlainText: 09e8075dbd91e11878f8f4a138df760c
|
||||
command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc
|
||||
command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1
|
||||
command.note.deleteNote: 56f727a2ee11d15159f1aa6373314cb6
|
||||
@@ -103,7 +104,9 @@ files:
|
||||
settings.sync.title: 36f62a237420dcdc7096ad3a9018c4d9
|
||||
settings.sync.description: 06a6957c40867be2c28783183dc1bf5f
|
||||
settings.pullInterval: 4f95ca677398604bca241427261ed07b
|
||||
settings.pullIntervalDescription: 4a75c15f44d59dc8cb100ec2d0693048
|
||||
settings.releaseChannel: 2da37055e15a217f18bd403922085c3f
|
||||
settings.releaseChannelDescription: c5ae9b7f25376df383d36603366ac1fa
|
||||
settings.releaseStable: fa3aff3c185c6dc7754235f397c2099a
|
||||
settings.releaseAlpha: 6132295fcf5570fb8b0a944ef322a598
|
||||
settings.appearance.title: a1c58e94227389415de133efdf78ea6e
|
||||
@@ -122,7 +125,9 @@ files:
|
||||
settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75
|
||||
settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554
|
||||
settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6
|
||||
settings.autogit.idleThresholdDescription: 521140491e8c9d5a1d7b7739bc333358
|
||||
settings.autogit.inactiveThreshold: d28cb60d1e70e020ae8d1294e32dd474
|
||||
settings.autogit.inactiveThresholdDescription: f9e43c90bd10bae937c2cfa5c2767f1c
|
||||
settings.titles.title: 761443f70a5067ecf015c6fb3fae9cef
|
||||
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
|
||||
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
|
||||
@@ -133,26 +138,75 @@ files:
|
||||
settings.vaultContent.hideGitignoredDescription: e8b1ddfa416610f9d6eb299fa123a1d6
|
||||
settings.allNotesVisibility.title: 0e07c3d48b91e1a4c42c1ff3e5751ec3
|
||||
settings.allNotesVisibility.description: bc9caa48296281401aad694fed65a2d9
|
||||
settings.allNotesVisibility.pdfs: 76663c34a88bd4225613a1a947127bcf
|
||||
settings.allNotesVisibility.pdfsDescription: 33bfa167c125ddd713f40af11d233121
|
||||
settings.allNotesVisibility.images: fff0d600f8a0b5e19e88bfb821dd1157
|
||||
settings.allNotesVisibility.imagesDescription: 54e1fe8f20b426de2fe457322862fb4b
|
||||
settings.allNotesVisibility.unsupported: 91de79ad2f20abd62e445f20584d3b8e
|
||||
settings.allNotesVisibility.unsupportedDescription: c1ecaa108af2de4680ab535585c475e8
|
||||
settings.allNotesVisibility.pdfs: 27ce5e1e1761ff362dbf75c76c6b4941
|
||||
settings.allNotesVisibility.pdfsDescription: 8191ed4a095cdd60ef856ce0ff213a48
|
||||
settings.allNotesVisibility.images: ea15460e63b6e8e92f8c03a79eeb6e4e
|
||||
settings.allNotesVisibility.imagesDescription: 1704e10d5e5cdc17d2d2a5fb7b8969bf
|
||||
settings.allNotesVisibility.unsupported: 257fe04419c9f6543a43e58b1edf9eb5
|
||||
settings.allNotesVisibility.unsupportedDescription: a3b8aa66900c742f001cd4533b3df44a
|
||||
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
|
||||
settings.aiAgents.description: a13222e67eb121676ff6dfc7b085f02d
|
||||
settings.aiAgents.description: 042a8037f1a4f90c76ce31a49fdff59c
|
||||
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
|
||||
settings.aiAgents.defaultTarget: 5acfc74fd97a6dd2d67d15c66de5eed5
|
||||
settings.aiAgents.agentGroup: 965530cbbec45b1754ed3ece120399f7
|
||||
settings.aiAgents.localGroup: d34f4b997ce78da5aa57d657a5d6fcb5
|
||||
settings.aiAgents.apiGroup: 589a257cbd265eaa7de93de8b4084dff
|
||||
settings.aiAgents.installed: 73329564760013a7824ff9d5d1af91ff
|
||||
settings.aiAgents.missing: ea21841da70e6405af19fabc4ff8bdd9
|
||||
settings.aiAgents.ready: 909a648c713be25fcd050725d0a41e37
|
||||
settings.aiAgents.notInstalled: 0ac3f76ef78bfdbab6331731ee56ba22
|
||||
settings.aiAgents.apiReady: e5b06f6ef4ef5f4bc2848d46195e3f48
|
||||
settings.aiAgents.apiLocalKey: 119de2f31867b273a134f05bc56b9e57
|
||||
settings.aiAgents.apiEnv: e011db3dff54ed3deae37a9615e31ebf
|
||||
settings.aiAgents.apiNoKey: 5bbccbfcd09b28902bbf5319dd6e6053
|
||||
settings.aiAgents.installedTitle: 5cb9b5ae5ebc66652814dff4584705cc
|
||||
settings.aiAgents.installedDescription: fb9ba9667725fb40fa1e5d58a2c02692
|
||||
settings.aiAgents.noVersion: 89831adb7114946e916b9c31d57a1e33
|
||||
settings.aiProviders.title: 63eb84d8510f9f086d282ec7d47adaa1
|
||||
settings.aiProviders.description: 6c86c013eb6fb198b51e29ec47d66464
|
||||
settings.aiProviders.localTitle: c7c20443d8aaf04271df9d713f5c02ce
|
||||
settings.aiProviders.localDescription: 7abed40ef2e46741c64ff47e423cca39
|
||||
settings.aiProviders.apiTitle: 66537b9e5200ebbbfbd091144d8afbcc
|
||||
settings.aiProviders.apiDescription: 6ec773352e05a805af3abcebeff4e756
|
||||
settings.aiProviders.kind: 27703c8f150ac4bb0a3a83a7857353af
|
||||
settings.aiProviders.kind.ollama: c23e8db458397f37c8e8d98e94e55a18
|
||||
settings.aiProviders.kind.lmStudio: 7b9b319662715e90583e823a6c3cfdbe
|
||||
settings.aiProviders.kind.openAi: 0523b13262b12c215d8009938f5c14f1
|
||||
settings.aiProviders.kind.anthropic: f431db65cea024a5f19eab835afefee2
|
||||
settings.aiProviders.kind.gemini: 766cc4dd4d5005652e8514e3513683f8
|
||||
settings.aiProviders.kind.openRouter: a0607683815b6585fb80e7cac72ac59a
|
||||
settings.aiProviders.kind.compatible: 1a4dc90a183c26d9026c6ac7727aa4cd
|
||||
settings.aiProviders.name: 49ee3087348e8d44e1feda1917443987
|
||||
settings.aiProviders.baseUrl: ade86bc4899761ad46c52e381b6228bb
|
||||
settings.aiProviders.model: 6bd36c36869288188863bd53a9bc5ed1
|
||||
settings.aiProviders.key: 656a6828d7ef1bb791e42087c4b5ee6e
|
||||
settings.aiProviders.keyPlaceholder: 57dbf3720af3f156018bfff7f5a99b23
|
||||
settings.aiProviders.keyStorage: 3e5b8cfbc66d3a33dad3fda058bf5851
|
||||
settings.aiProviders.keyStorage.local: 7c6189f734f3c66d162a3f06529af1d9
|
||||
settings.aiProviders.keyStorage.env: a71b071971d7c54e9af381c9bea093d9
|
||||
settings.aiProviders.keyStorage.none: 94c841331ccd5179a283487f96285c90
|
||||
settings.aiProviders.keyEnv: cb72671bd5cf65512d4c94e8ad6182ef
|
||||
settings.aiProviders.keySafety: 0d59d7e4def35e51c4bb785d74180afc
|
||||
settings.aiProviders.keySafetyLocal: 8ef2ba0192ac725fa1a3e737e852387c
|
||||
settings.aiProviders.localSafety: 60fb65e4e0d35cec3135584d8219e8fd
|
||||
settings.aiProviders.add: a9f8e8b4ca3cbc4fc3bff5d59d6a5419
|
||||
settings.aiProviders.addLocal: 681985f2e412f22fcd55a0553f6e4bee
|
||||
settings.aiProviders.addApi: 767b46f2a51aa9dd050cb226cd86e192
|
||||
settings.aiProviders.test: 602009d5aea1c1ef06fd777968f7e80e
|
||||
settings.aiProviders.testing: 9d770c909c2c69b09eae2372c4cf405d
|
||||
settings.aiProviders.testSuccess: 51677ed54231e41a4fe40850d90b64b2
|
||||
settings.aiProviders.empty: 9378027dec7f93fcc5011c5e528bdf82
|
||||
settings.aiProviders.defaultEndpoint: 666b355c305dd4c7b4649d3d7b4c5d11
|
||||
settings.aiProviders.keyLocalSaved: f4a102d6a67212e21f0f31f0758aea81
|
||||
settings.aiProviders.keyEnvSaved: bfed2d745dd76dbea9341d27e06c39f2
|
||||
settings.aiProviders.noKey: 1ab089f80ef4dc6c1ce3e173f5b405a0
|
||||
settings.workflow.title: 24f47cdbe9ddba774a7cc53e51d9032e
|
||||
settings.workflow.description: aa9a07539b87cf407c3edc8a22732d45
|
||||
settings.workflow.explicit: 54a5b5c27937d25271c3127d093e8361
|
||||
settings.workflow.explicitDescription: ce523427b5dd0f9895f63d1fbb90ad24
|
||||
settings.workflow.autoAdvance: 9ed337f5bc61603d19a1ef35f3a3a243
|
||||
settings.workflow.autoAdvanceDescription: ae58ad8abf03df7cbdae8c3a725258f7
|
||||
settings.privacy.title: 0dd6c14e00cc9f45a68c1bca88d1317d
|
||||
settings.privacy.title: aa96a21412def0d916f43b639424f8e4
|
||||
settings.privacy.description: 33d53059be620b58e38b8e5c5ab26b27
|
||||
settings.privacy.crashReporting: b4efc5b61526566d431e99242f5c21b4
|
||||
settings.privacy.crashReportingDescription: e9d7c7efce44adc08d0460be1aad5c42
|
||||
@@ -164,6 +218,7 @@ files:
|
||||
common.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
common.create: 686e697538050e4664636337cc3b834f
|
||||
common.save: c9cc8cce247e49bae79f15173ce97354
|
||||
common.remove: 1063e38cb53d94d386f21227fcd84717
|
||||
customize.color: cb5feb1b7314637725a2e73bdc9f7295
|
||||
customize.icon: 817434295a673aed431435658b65d9a7
|
||||
customize.searchIcons: 0688a8098567785a05cb4cf937cbb880
|
||||
@@ -193,6 +248,8 @@ files:
|
||||
ai.panel.status.checking: 118740af1c4521b917e29791d661db82
|
||||
ai.panel.status.missing: fe07f8eac233c229d81cbe9aa25668a0
|
||||
ai.panel.status.ready: 296a0ac791eab2df130789f55fdc109b
|
||||
ai.panel.mode.chat: 55dcdf017b51fc96f7b5f9d63013b95d
|
||||
ai.panel.mode.chatDescription: d7744ae39e7920a8eac7b21b58fce348
|
||||
ai.panel.copyMcpConfig: 6a8c9fe401557c870e3a90bbfab01b25
|
||||
ai.panel.mcpConfig: 53bac93d0314941c8d2c1163026f3ad9
|
||||
ai.panel.newChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
|
||||
@@ -364,12 +421,24 @@ files:
|
||||
editor.toolbar.showDiff: 08d579de8d3abdcdfce0953a797362c6
|
||||
editor.toolbar.openAi: d2e93006570a66709c8ce0dc27082ef1
|
||||
editor.toolbar.closeAi: cd46973ef73076d612dff9903ce54498
|
||||
editor.toolbar.openTableOfContents: b733f053ea3fde4a9acd589ec7f58c10
|
||||
editor.toolbar.closeTableOfContents: d3292972a10edd1a06a627f3552f760a
|
||||
editor.toolbar.restoreArchived: 1bff5cf4943af64c05b2e9aeadccbc84
|
||||
editor.toolbar.archive: 63dc964c32e715217b49f8739d49636b
|
||||
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
|
||||
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
|
||||
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
|
||||
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
|
||||
tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b
|
||||
tableOfContents.close: d3292972a10edd1a06a627f3552f760a
|
||||
tableOfContents.empty: e4f02ad69cbbce1ec65d14215e3d5871
|
||||
tableOfContents.emptyNoNote: 046d95682b747a30ed8a7b0b1d581629
|
||||
tableOfContents.navLabel: 598b8ae10dfce818e73d3218daddbdae
|
||||
tableOfContents.untitledHeading: 93c2dde207965b383b7b22af005ebe4f
|
||||
tableOfContents.expandHeading: 6353ab44fe75f5dd935789bdab8551a0
|
||||
tableOfContents.collapseHeading: 040b4c102283028831ea78713235e478
|
||||
editor.codeBlock.copy: 8e477020fb3f7ab844b91ff1d7de8347
|
||||
editor.imageLightbox.title: 2c5a15c875bcdc2478c4a5cad044e10c
|
||||
editor.filename.rename: c31c2b468229232ad6287e734fe67d96
|
||||
editor.filename.renameToTitle: bff34a6a2dd1eb87c59403946679237f
|
||||
@@ -384,6 +453,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
|
||||
@@ -486,6 +557,7 @@ files:
|
||||
status.ai.noAgentsTooltip: 1bdc02ec2dd7f9701b4371ffd5770318
|
||||
status.ai.selectedMissing: a37f3dbc73725219e90d709fe0d3ad97
|
||||
status.ai.defaultAgent: 79650d57d1678e56c75dbbcba6ea87f7
|
||||
status.ai.defaultTarget: 65ea5bdb609833c4baffd2cbd0e86542
|
||||
status.ai.restoreDetails: 47a913b58ce40add6521bc93e807476f
|
||||
status.ai.withGuidance: 1eafd34810db6bba54dac7aa549b5182
|
||||
status.ai.active: 059e3a3167c2ab00f4ca872e82a48d98
|
||||
@@ -495,6 +567,9 @@ files:
|
||||
status.ai.vaultGuidance: 7bb4644efe6ae7cec9993c8bd85f1519
|
||||
status.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
|
||||
status.ai.openOptions: d387b1ab67586c7a2d83db8900a4dd8e
|
||||
status.ai.modelTargets: 8f3ceb96e088f8bdaecc236778401ac9
|
||||
status.ai.localChat: 215a24e3d38fb8b68deea6dad0142326
|
||||
status.ai.apiChat: 6dffd5b9997885efe701631cea5c470d
|
||||
pulse.title: 16d2b386b2034b9488996466aaae0b57
|
||||
pulse.today: 1dd1c5fb7f25cd41b291d43a89e3aefd
|
||||
pulse.yesterday: ebfe9ce86e6e9fb953aa7a25b59c1956
|
||||
@@ -524,3 +599,4 @@ files:
|
||||
locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e
|
||||
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
|
||||
locale.vi: 7b80fae85640c16cdb0261bef0c27636
|
||||
locale.plPL: c730389bc8d99e59c867766babdd48b5
|
||||
|
||||
@@ -162,15 +162,6 @@ const TOOLS = [
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
search_notes: handleSearchNotes,
|
||||
get_vault_context: handleVaultContext,
|
||||
get_note: handleGetNote,
|
||||
open_note: handleOpenNote,
|
||||
highlight_editor: handleHighlightEditor,
|
||||
refresh_vault: handleRefreshVault,
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
const results = await searchNotes(VAULT_PATH, args.query, args.limit)
|
||||
const text = results.length === 0
|
||||
@@ -207,6 +198,25 @@ function handleRefreshVault(args) {
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
function callToolHandler(name, args) {
|
||||
switch (name) {
|
||||
case 'search_notes':
|
||||
return handleSearchNotes(args)
|
||||
case 'get_vault_context':
|
||||
return handleVaultContext()
|
||||
case 'get_note':
|
||||
return handleGetNote(args)
|
||||
case 'open_note':
|
||||
return handleOpenNote(args)
|
||||
case 'highlight_editor':
|
||||
return handleHighlightEditor(args)
|
||||
case 'refresh_vault':
|
||||
return handleRefreshVault(args)
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
@@ -220,12 +230,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
const handler = TOOL_HANDLERS[name]
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
try {
|
||||
return await handler(args)
|
||||
return await callToolHandler(name, args)
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
||||
|
||||
32
mcp-server/package-lock.json
generated
32
mcp-server/package-lock.json
generated
@@ -14,9 +14,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"version": "1.19.13",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
|
||||
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -431,12 +431,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.2.tgz",
|
||||
"integrity": "sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -619,9 +619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz",
|
||||
"integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==",
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -670,9 +670,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -882,9 +882,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
|
||||
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -12,5 +12,11 @@
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.13",
|
||||
"express-rate-limit": "8.2.2",
|
||||
"hono": "4.12.14",
|
||||
"path-to-regexp": "8.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import {
|
||||
mkdtemp, mkdir, open, rm,
|
||||
} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -16,12 +18,12 @@ const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
|
||||
const MCP_SERVER_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
|
||||
await fs.mkdir(path.join(tmpDir, 'project'), { recursive: true })
|
||||
await fs.mkdir(path.join(tmpDir, 'note'), { recursive: true })
|
||||
await mkdir(path.join(tmpDir, 'project'), { recursive: true })
|
||||
await mkdir(path.join(tmpDir, 'note'), { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'project', 'test-project.md'), `---
|
||||
await writeTextFile(path.join(tmpDir, 'project', 'test-project.md'), `---
|
||||
title: Test Project
|
||||
is_a: Project
|
||||
status: Active
|
||||
@@ -32,7 +34,7 @@ status: Active
|
||||
This is a test project for the MCP server.
|
||||
`)
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'note', 'daily-log.md'), `---
|
||||
await writeTextFile(path.join(tmpDir, 'note', 'daily-log.md'), `---
|
||||
title: Daily Log
|
||||
is_a: Note
|
||||
---
|
||||
@@ -42,7 +44,7 @@ is_a: Note
|
||||
Today I worked on the MCP server implementation.
|
||||
`)
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'project', 'second-project.md'), `---
|
||||
await writeTextFile(path.join(tmpDir, 'project', 'second-project.md'), `---
|
||||
title: Second Project
|
||||
type: Project
|
||||
status: Draft
|
||||
@@ -57,7 +59,7 @@ Another project for testing list and context.
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('findMarkdownFiles', () => {
|
||||
@@ -240,17 +242,26 @@ describe('stdio process lifecycle', () => {
|
||||
})
|
||||
|
||||
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideDir = await mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideNote = path.join(outsideDir, 'outside.md')
|
||||
|
||||
try {
|
||||
await fs.writeFile(outsideNote, '# Outside\n')
|
||||
await writeTextFile(outsideNote, '# Outside\n')
|
||||
await assert.rejects(
|
||||
() => getNote(tmpDir, resolveNotePath(outsideNote)),
|
||||
{ message: ACTIVE_VAULT_ERROR },
|
||||
)
|
||||
} finally {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true })
|
||||
await rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTextFile(filePath, content) {
|
||||
const handle = await open(filePath, 'w')
|
||||
try {
|
||||
await handle.writeFile(content, 'utf-8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Write operations are handled by the app-managed agent's active permission
|
||||
* profile and native file-edit tools when available.
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import { open, opendir, realpath } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
|
||||
@@ -16,17 +16,17 @@ const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
|
||||
*/
|
||||
export async function findMarkdownFiles(dir) {
|
||||
const results = []
|
||||
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const items = await opendir(dir)
|
||||
for await (const item of items) {
|
||||
await collectMarkdownFile(results, dir, item)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function resolveVaultNotePath(vaultPath, notePath) {
|
||||
const vaultRoot = await fs.realpath(vaultPath)
|
||||
const vaultRoot = await realpath(vaultPath)
|
||||
const requestedPath = resolveRequestedNotePath(vaultRoot, notePath)
|
||||
const noteRealPath = await fs.realpath(requestedPath)
|
||||
const noteRealPath = await realpath(requestedPath)
|
||||
const relativePath = path.relative(vaultRoot, noteRealPath)
|
||||
|
||||
if (!isVaultRelativePath(relativePath)) {
|
||||
@@ -51,7 +51,7 @@ export async function getNote(vaultPath, notePath) {
|
||||
noteRealPath,
|
||||
relativePath,
|
||||
} = await resolveVaultNotePath(vaultPath, notePath)
|
||||
const raw = await fs.readFile(noteRealPath, 'utf-8')
|
||||
const raw = await readUtf8File(noteRealPath)
|
||||
const parsed = matter(raw)
|
||||
return {
|
||||
path: relativePath,
|
||||
@@ -74,7 +74,7 @@ export async function searchNotes(vaultPath, query, limit = 10) {
|
||||
|
||||
for (const filePath of files) {
|
||||
if (results.length >= limit) break
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const content = await readUtf8File(filePath)
|
||||
const filename = path.basename(filePath, '.md')
|
||||
const titleMatch = extractTitle(content, filename)
|
||||
if (!matchesSearchQuery(titleMatch, content, q)) continue
|
||||
@@ -126,7 +126,8 @@ export async function vaultContext(vaultPath) {
|
||||
async function collectMarkdownFile(results, dir, item) {
|
||||
if (item.name.startsWith('.')) return
|
||||
|
||||
const full = path.join(dir, item.name)
|
||||
const full = resolveInside(dir, item.name)
|
||||
if (!full) return
|
||||
if (item.isDirectory()) {
|
||||
results.push(...await findMarkdownFiles(full))
|
||||
return
|
||||
@@ -139,7 +140,16 @@ async function collectMarkdownFile(results, dir, item) {
|
||||
|
||||
function resolveRequestedNotePath(vaultRoot, notePath) {
|
||||
if (path.isAbsolute(notePath)) return notePath
|
||||
return path.resolve(vaultRoot, notePath)
|
||||
const resolved = resolveInside(vaultRoot, notePath)
|
||||
if (!resolved) throw new Error(ACTIVE_VAULT_ERROR)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function resolveInside(root, target) {
|
||||
const resolved = path.resolve(root, target)
|
||||
const relative = path.relative(root, resolved)
|
||||
if (isVaultRelativePath(relative)) return resolved
|
||||
return null
|
||||
}
|
||||
|
||||
function isVaultRelativePath(relativePath) {
|
||||
@@ -151,11 +161,11 @@ function matchesSearchQuery(title, content, query) {
|
||||
}
|
||||
|
||||
async function readVaultContextNote(vaultPath, filePath) {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const raw = await readUtf8File(filePath)
|
||||
const parsed = matter(raw)
|
||||
const rel = path.relative(vaultPath, filePath)
|
||||
const topFolder = extractTopFolder(rel)
|
||||
const stat = await fs.stat(filePath)
|
||||
const stat = await statFile(filePath)
|
||||
const type = parsed.data.type || parsed.data.is_a || null
|
||||
|
||||
return {
|
||||
@@ -179,8 +189,8 @@ async function readConfigFiles(vaultPath) {
|
||||
const configFiles = {}
|
||||
|
||||
try {
|
||||
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
|
||||
configFiles.agents = await fs.readFile(agentsPath, 'utf-8')
|
||||
const agentsPath = resolveInside(vaultPath, 'config/agents.md')
|
||||
if (agentsPath) configFiles.agents = await readUtf8File(agentsPath)
|
||||
} catch {
|
||||
// config/agents.md may not exist yet
|
||||
}
|
||||
@@ -188,6 +198,24 @@ async function readConfigFiles(vaultPath) {
|
||||
return configFiles
|
||||
}
|
||||
|
||||
async function readUtf8File(filePath) {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
return await handle.readFile('utf-8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function statFile(filePath) {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
return await handle.stat()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract title from markdown content (first H1 or frontmatter title).
|
||||
* @param {string} content
|
||||
|
||||
@@ -38,6 +38,7 @@ const TRUSTED_UI_ORIGINS = new Set([
|
||||
/** @type {WebSocketServer | null} */
|
||||
let uiBridge = null
|
||||
let vaultPath = null
|
||||
const UNKNOWN_TOOL = Symbol('unknown tool')
|
||||
|
||||
function activeVaultPath() {
|
||||
vaultPath ??= requireVaultPath()
|
||||
@@ -53,30 +54,65 @@ function broadcastUiAction(action, payload) {
|
||||
}
|
||||
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
read_note: (args) => getNote(activeVaultPath(), args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(activeVaultPath(), args.query, args.limit),
|
||||
vault_context: () => vaultContext(activeVaultPath()),
|
||||
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
|
||||
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
|
||||
async function readNoteTool(args) {
|
||||
const note = await getNote(activeVaultPath(), args.path)
|
||||
return { content: note.content, frontmatter: note.frontmatter }
|
||||
}
|
||||
|
||||
function uiOpenNoteTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_note', { path: args.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function uiOpenTabTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function highlightTool(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function uiSetFilterTool(args) {
|
||||
broadcastUiAction('set_filter', { filterType: args.type })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function refreshVaultTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: args?.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const TOOL_EXECUTORS = [
|
||||
['open_note', readNoteTool],
|
||||
['read_note', readNoteTool],
|
||||
['search_notes', (args) => searchNotes(activeVaultPath(), args.query, args.limit)],
|
||||
['vault_context', () => vaultContext(activeVaultPath())],
|
||||
['ui_open_note', uiOpenNoteTool],
|
||||
['ui_open_tab', uiOpenTabTool],
|
||||
['ui_highlight', highlightTool],
|
||||
['highlight_editor', highlightTool],
|
||||
['ui_set_filter', uiSetFilterTool],
|
||||
['refresh_vault', refreshVaultTool],
|
||||
]
|
||||
|
||||
function callToolHandler(tool, args) {
|
||||
const executor = TOOL_EXECUTORS.find(([name]) => name === tool)?.[1]
|
||||
return executor ? executor(args) : UNKNOWN_TOOL
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
const msg = JSON.parse(data)
|
||||
const { id, tool, args } = msg
|
||||
|
||||
const handler = TOOL_HANDLERS[tool]
|
||||
if (!handler) {
|
||||
return { id, error: `Unknown tool: ${tool}` }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(args || {})
|
||||
const result = await callToolHandler(tool, args || {})
|
||||
if (result === UNKNOWN_TOOL) {
|
||||
return { id, error: `Unknown tool: ${tool}` }
|
||||
}
|
||||
return { id, result }
|
||||
} catch (err) {
|
||||
return { id, error: err.message }
|
||||
|
||||
19
package.json
19
package.json
@@ -17,7 +17,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.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",
|
||||
@@ -58,6 +58,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "3.4.2",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mermaid": "^11.14.0",
|
||||
@@ -70,8 +71,10 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-regex2": "5.1.1",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tldraw": "^4.5.10",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unicode-emoji-json": "^0.8.0"
|
||||
},
|
||||
@@ -98,8 +101,20 @@
|
||||
"jsdom": "^28.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.2",
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.13",
|
||||
"express-rate-limit": "8.2.2",
|
||||
"hono": "4.12.14",
|
||||
"path-to-regexp": "8.4.0",
|
||||
"picomatch": "4.0.4",
|
||||
"postcss": "8.5.10",
|
||||
"protobufjs": "7.5.6",
|
||||
"rollup": "4.59.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
904
pnpm-lock.yaml
generated
904
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
18
release-notes/stable-v2026.5.2.md
Normal file
18
release-notes/stable-v2026.5.2.md
Normal file
@@ -0,0 +1,18 @@
|
||||
## New Features
|
||||
|
||||
- 📋 **Paste Without Formatting** — Paste copied text as plain content without bringing unwanted styling into a note.
|
||||
- 🇵🇱 **Polish Language Support** — Use Tolaria with a new Polish interface translation.
|
||||
- 🧭 **Refined Titlebar Navigation** — Navigate with clearer titlebar controls that feel more native on desktop.
|
||||
|
||||
## Improvements
|
||||
|
||||
- ⚡ **Faster Note Loading** — Switch between notes more smoothly by reusing cached note content and parsed editor blocks.
|
||||
- 🗂️ **Cleaner Sidebar and Menus** — Scan folders, sidebar sections, slash commands, icon choices, and release tabs with cleaner spacing and styling.
|
||||
- 🖥️ **Better Cross-Platform Window Controls** — Use macOS and Linux window controls that better match each platform.
|
||||
- ☀️ **Improved Light Mode Editing** — Read code blocks more comfortably when Tolaria is using the light theme.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- This release also improves editor reliability around block dragging, table handles, pasted Markdown, stale side-menu actions, and unrelated vault refreshes.
|
||||
- Vault, type, and frontmatter handling were hardened to prevent freezes, filename collisions, parse failures, and stale saved-view deletes.
|
||||
- Startup, Linux AppImage, release CI, and AI/Codex integration fixes are included in the full commit list.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { basename, dirname, extname, resolve } from 'node:path'
|
||||
|
||||
import { buildReleaseHistoryPage } from '../src/utils/releaseHistoryPage'
|
||||
|
||||
@@ -14,6 +14,12 @@ function getArg(flag: string): string {
|
||||
return value
|
||||
}
|
||||
|
||||
function getOptionalArg(flag: string): string | null {
|
||||
const index = process.argv.indexOf(flag)
|
||||
const value = index >= 0 ? process.argv[index + 1] : null
|
||||
return value || null
|
||||
}
|
||||
|
||||
function readReleasePayload(filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
@@ -22,10 +28,28 @@ function readReleasePayload(filePath: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
function readReadableReleaseNotes(directoryPath: string | null): Record<string, string> {
|
||||
if (directoryPath === null) return {}
|
||||
|
||||
const resolvedDirectory = resolve(directoryPath)
|
||||
if (!existsSync(resolvedDirectory)) return {}
|
||||
|
||||
return Object.fromEntries(
|
||||
readdirSync(resolvedDirectory)
|
||||
.filter(fileName => extname(fileName) === '.md')
|
||||
.map(fileName => {
|
||||
const filePath = resolve(resolvedDirectory, fileName)
|
||||
const tagName = basename(fileName, '.md')
|
||||
return [tagName, readFileSync(filePath, 'utf8')]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const releasesJsonPath = resolve(getArg('--releases-json'))
|
||||
const outputFilePath = resolve(getArg('--output-file'))
|
||||
const releasesPayload = readReleasePayload(releasesJsonPath)
|
||||
const html = buildReleaseHistoryPage(releasesPayload)
|
||||
const readableReleaseNotes = readReadableReleaseNotes(getOptionalArg('--release-notes-dir'))
|
||||
const html = buildReleaseHistoryPage(releasesPayload, readableReleaseNotes)
|
||||
|
||||
mkdirSync(dirname(outputFilePath), { recursive: true })
|
||||
writeFileSync(outputFilePath, html)
|
||||
|
||||
@@ -12,6 +12,9 @@ const forwardedArgs = process.argv.slice(2)
|
||||
const hasFileParallelismOverride = forwardedArgs.some((arg) =>
|
||||
arg === '--fileParallelism' || arg === '--no-file-parallelism'
|
||||
)
|
||||
const hasMaxWorkersOverride = forwardedArgs.some((arg) =>
|
||||
arg === '--maxWorkers' || arg.startsWith('--maxWorkers=')
|
||||
)
|
||||
const maxAttempts = 2
|
||||
|
||||
const packageManagerExec = process.env.npm_execpath
|
||||
@@ -46,10 +49,11 @@ async function runCoverageAttempt(attempt) {
|
||||
|
||||
const commandArgs = [
|
||||
...baseCommandArgs,
|
||||
// Vitest 4.0.18 occasionally crashes during coverage worker teardown
|
||||
// after all files pass, so serialize file execution unless a caller
|
||||
// explicitly opts into a different file-parallelism mode.
|
||||
...(hasFileParallelismOverride ? [] : ['--no-file-parallelism']),
|
||||
// Keep coverage fast enough for CI while avoiding the unbounded worker
|
||||
// contention that makes a few DOM-heavy suites time out under full
|
||||
// file parallelism. Callers can still opt into serial or wider runs.
|
||||
...(hasFileParallelismOverride ? [] : ['--fileParallelism']),
|
||||
...(hasMaxWorkersOverride ? [] : ['--maxWorkers=4']),
|
||||
`--coverage.reportsDirectory=${runCoverageDir}`,
|
||||
...forwardedArgs,
|
||||
]
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
*/
|
||||
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import {
|
||||
closeSync,
|
||||
createReadStream,
|
||||
fstatSync,
|
||||
openSync,
|
||||
opendirSync,
|
||||
readFileSync,
|
||||
} from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import matter from 'gray-matter'
|
||||
@@ -16,8 +23,71 @@ const REPO_DIR = path.resolve(__dirname, '..')
|
||||
const PORT = 5173
|
||||
|
||||
function isAllowedPath(p) {
|
||||
const resolved = path.resolve(p)
|
||||
return resolved.startsWith(REPO_DIR)
|
||||
return isInsideRelativePath(path.relative(REPO_DIR, p))
|
||||
}
|
||||
|
||||
function isInsideRelativePath(relative) {
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function resolveInside(root, target) {
|
||||
const normalizedTarget = path.normalize(target)
|
||||
if (path.isAbsolute(normalizedTarget)) return null
|
||||
const candidate = path.normalize(`${root}${path.sep}${normalizedTarget}`)
|
||||
return isInsideRelativePath(path.relative(root, candidate)) ? candidate : null
|
||||
}
|
||||
|
||||
function readUtf8File(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return readFileSync(fd, 'utf-8')
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function pathStats(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return fstatSync(fd)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function pathExists(filePath) {
|
||||
try {
|
||||
pathStats(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function directoryEntries(dir) {
|
||||
const directory = opendirSync(dir)
|
||||
try {
|
||||
const entries = []
|
||||
let entry = directory.readSync()
|
||||
while (entry) {
|
||||
entries.push(entry)
|
||||
entry = directory.readSync()
|
||||
}
|
||||
return entries
|
||||
} finally {
|
||||
directory.closeSync()
|
||||
}
|
||||
}
|
||||
|
||||
function streamFile(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
return createReadStream(null, { fd, autoClose: true })
|
||||
}
|
||||
|
||||
function staticAssetPath(url) {
|
||||
const pathname = new URL(url, 'http://localhost').pathname
|
||||
const requested = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '')
|
||||
return resolveInside(DIST_DIR, requested) ?? path.normalize(`${DIST_DIR}${path.sep}index.html`)
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
@@ -34,8 +104,9 @@ const MIME = {
|
||||
function findMarkdownFiles(dir) {
|
||||
const results = []
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
for (const entry of directoryEntries(dir)) {
|
||||
const full = resolveInside(dir, entry.name)
|
||||
if (!full) continue
|
||||
if (entry.isDirectory()) results.push(...findMarkdownFiles(full))
|
||||
else if (entry.name.endsWith('.md')) results.push(full)
|
||||
}
|
||||
@@ -51,9 +122,9 @@ function extractWikiLinks(value) {
|
||||
|
||||
function parseMarkdownFile(filePath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const raw = readUtf8File(filePath)
|
||||
const { data: fm, content } = matter(raw)
|
||||
const stat = fs.statSync(filePath)
|
||||
const stat = pathStats(filePath)
|
||||
|
||||
const DEDICATED = new Set(['aliases','Is A','Belongs to','Related to','Status','Owner','Cadence','Created at'])
|
||||
const relationships = {}
|
||||
@@ -98,7 +169,7 @@ function serveVaultApi(url, res) {
|
||||
|
||||
if (params.pathname === '/api/vault/list') {
|
||||
const dir = params.searchParams.get('path')
|
||||
if (!dir || !isAllowedPath(dir) || !fs.existsSync(dir)) {
|
||||
if (!dir || !isAllowedPath(dir) || !pathExists(dir)) {
|
||||
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
||||
}
|
||||
const entries = findMarkdownFiles(dir).map(parseMarkdownFile).filter(Boolean)
|
||||
@@ -109,22 +180,22 @@ function serveVaultApi(url, res) {
|
||||
|
||||
if (params.pathname === '/api/vault/content') {
|
||||
const file = params.searchParams.get('path')
|
||||
if (!file || !isAllowedPath(file) || !fs.existsSync(file)) {
|
||||
if (!file || !isAllowedPath(file) || !pathExists(file)) {
|
||||
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ content: fs.readFileSync(file, 'utf-8') }))
|
||||
res.end(JSON.stringify({ content: readUtf8File(file) }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (params.pathname === '/api/vault/all-content') {
|
||||
const dir = params.searchParams.get('path')
|
||||
if (!dir || !isAllowedPath(dir) || !fs.existsSync(dir)) {
|
||||
if (!dir || !isAllowedPath(dir) || !pathExists(dir)) {
|
||||
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
||||
}
|
||||
const map = {}
|
||||
for (const f of findMarkdownFiles(dir)) {
|
||||
try { map[f] = fs.readFileSync(f, 'utf-8') } catch {}
|
||||
try { map[f] = readUtf8File(f) } catch {}
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(map))
|
||||
@@ -146,13 +217,13 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
|
||||
// Static files
|
||||
let filePath = path.join(DIST_DIR, url === '/' ? 'index.html' : url)
|
||||
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(DIST_DIR, 'index.html') // SPA fallback
|
||||
let filePath = staticAssetPath(url)
|
||||
if (!pathExists(filePath) || pathStats(filePath).isDirectory()) {
|
||||
filePath = path.normalize(`${DIST_DIR}${path.sep}index.html`) // SPA fallback
|
||||
}
|
||||
const ext = path.extname(filePath)
|
||||
res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' })
|
||||
fs.createReadStream(filePath).pipe(res)
|
||||
streamFile(filePath).pipe(res)
|
||||
})
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from 'node:fs'
|
||||
import {
|
||||
closeSync, fstatSync, openSync, opendirSync, readFileSync,
|
||||
} from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
@@ -7,7 +9,42 @@ const localesDir = path.join(root, 'src/lib/locales')
|
||||
const sourcePath = path.join(localesDir, 'en.json')
|
||||
|
||||
function readCatalog(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
return JSON.parse(readUtf8File(filePath))
|
||||
}
|
||||
|
||||
function readUtf8File(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return readFileSync(fd, 'utf8')
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function directoryFiles(dirPath) {
|
||||
const dir = opendirSync(dirPath)
|
||||
try {
|
||||
const files = []
|
||||
let entry = dir.readSync()
|
||||
while (entry) {
|
||||
if (entry.isFile()) files.push(entry.name)
|
||||
entry = dir.readSync()
|
||||
}
|
||||
return files
|
||||
} finally {
|
||||
dir.closeSync()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDirectory(dirPath) {
|
||||
const fd = openSync(dirPath, 'r')
|
||||
try {
|
||||
if (!fstatSync(fd).isDirectory()) {
|
||||
throw new Error(`${dirPath} is not a directory`)
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function isFlatObject(value) {
|
||||
@@ -74,7 +111,8 @@ const sourceCatalog = readCatalog(sourcePath)
|
||||
assertFlatStringCatalog('en', sourceCatalog)
|
||||
|
||||
const sourceKeys = Object.keys(sourceCatalog).sort()
|
||||
const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith('.json'))
|
||||
ensureDirectory(localesDir)
|
||||
const localeFiles = directoryFiles(localesDir).filter((file) => file.endsWith('.json'))
|
||||
const issues = []
|
||||
|
||||
for (const file of localeFiles) {
|
||||
|
||||
155
src-tauri/Cargo.lock
generated
155
src-tauri/Cargo.lock
generated
@@ -892,7 +892,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1079,7 +1079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1486,8 +1486,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1497,9 +1499,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1857,6 +1861,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2361,6 +2366,12 @@ dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -2897,9 +2908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
version = "0.10.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
@@ -2929,9 +2940,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
version = "0.9.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -3391,6 +3402,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
@@ -3437,6 +3503,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3457,6 +3533,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3475,6 +3561,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3612,6 +3707,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
@@ -3619,6 +3715,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3626,6 +3724,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
@@ -3633,6 +3732,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3763,6 +3863,12 @@ version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3782,7 +3888,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3817,6 +3923,7 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3838,7 +3945,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3849,9 +3956,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4603,9 +4710,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -5002,7 +5109,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5185,6 +5292,7 @@ dependencies = [
|
||||
"log",
|
||||
"notify",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5808,6 +5916,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk"
|
||||
version = "2.0.2"
|
||||
@@ -5861,6 +5979,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -5919,7 +6046,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -39,5 +39,6 @@ tauri-plugin-prevent-default = "4.0.4"
|
||||
sentry = "0.37"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tempfile = "3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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",
|
||||
|
||||
701
src-tauri/src/ai_models.rs
Normal file
701
src-tauri/src/ai_models.rs
Normal file
@@ -0,0 +1,701 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiModelProviderKind {
|
||||
OpenAi,
|
||||
Anthropic,
|
||||
OpenAiCompatible,
|
||||
Ollama,
|
||||
LmStudio,
|
||||
OpenRouter,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiModelApiKeyStorage {
|
||||
None,
|
||||
Env,
|
||||
LocalFile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AiModelCapabilities {
|
||||
pub streaming: bool,
|
||||
pub tools: bool,
|
||||
pub vision: bool,
|
||||
pub json_mode: bool,
|
||||
pub reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AiModelDefinition {
|
||||
pub id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub context_window: Option<u32>,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
pub capabilities: AiModelCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AiModelProvider {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: AiModelProviderKind,
|
||||
pub base_url: Option<String>,
|
||||
pub api_key_storage: Option<AiModelApiKeyStorage>,
|
||||
pub api_key_env_var: Option<String>,
|
||||
pub headers: Option<BTreeMap<String, String>>,
|
||||
pub models: Vec<AiModelDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AiModelStreamRequest {
|
||||
pub provider: AiModelProvider,
|
||||
pub model_id: String,
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub api_key_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AiModelProviderTestRequest {
|
||||
pub provider: AiModelProvider,
|
||||
pub model_id: String,
|
||||
pub api_key_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct AiModelProviderCatalogEntry {
|
||||
kind: AiModelProviderKind,
|
||||
runtime_base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
struct AiProviderSecrets {
|
||||
provider_api_keys: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
static AI_MODEL_PROVIDER_CATALOG: OnceLock<Vec<AiModelProviderCatalogEntry>> = OnceLock::new();
|
||||
|
||||
fn provider_catalog() -> &'static [AiModelProviderCatalogEntry] {
|
||||
AI_MODEL_PROVIDER_CATALOG
|
||||
.get_or_init(|| {
|
||||
serde_json::from_str(include_str!("../../src/shared/aiModelProviderCatalog.json"))
|
||||
.expect("bundled AI model provider catalog must be valid JSON")
|
||||
})
|
||||
.as_slice()
|
||||
}
|
||||
|
||||
fn provider_default_base_url(kind: &AiModelProviderKind) -> Option<&'static str> {
|
||||
provider_catalog()
|
||||
.iter()
|
||||
.find(|entry| entry.kind == *kind)
|
||||
.and_then(|entry| entry.runtime_base_url.as_deref())
|
||||
}
|
||||
|
||||
pub fn normalize_ai_model_providers(
|
||||
providers: Option<Vec<AiModelProvider>>,
|
||||
) -> Option<Vec<AiModelProvider>> {
|
||||
let normalized = providers?
|
||||
.into_iter()
|
||||
.filter_map(normalize_ai_model_provider)
|
||||
.collect::<Vec<_>>();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ai_model_provider(mut provider: AiModelProvider) -> Option<AiModelProvider> {
|
||||
provider.id = provider.id.trim().to_ascii_lowercase();
|
||||
provider.name = provider.name.trim().to_string();
|
||||
provider.base_url = normalize_optional_string(provider.base_url);
|
||||
provider.api_key_env_var = normalize_optional_string(provider.api_key_env_var);
|
||||
provider.api_key_storage = normalize_api_key_storage(&provider);
|
||||
provider.models = normalized_models(provider.models);
|
||||
|
||||
is_valid_provider(&provider).then_some(provider)
|
||||
}
|
||||
|
||||
fn normalize_api_key_storage(provider: &AiModelProvider) -> Option<AiModelApiKeyStorage> {
|
||||
match provider.api_key_storage {
|
||||
Some(AiModelApiKeyStorage::LocalFile) => Some(AiModelApiKeyStorage::LocalFile),
|
||||
Some(AiModelApiKeyStorage::Env) | None if provider.api_key_env_var.is_some() => {
|
||||
Some(AiModelApiKeyStorage::Env)
|
||||
}
|
||||
_ => Some(AiModelApiKeyStorage::None),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_models(models: Vec<AiModelDefinition>) -> Vec<AiModelDefinition> {
|
||||
models
|
||||
.into_iter()
|
||||
.filter_map(|mut model| {
|
||||
model.id = model.id.trim().to_string();
|
||||
model.display_name = normalize_optional_string(model.display_name);
|
||||
(!model.id.is_empty()).then_some(model)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_valid_provider(provider: &AiModelProvider) -> bool {
|
||||
!provider.id.is_empty() && !provider.name.is_empty() && !provider.models.is_empty()
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|candidate| candidate.trim().to_string())
|
||||
.filter(|candidate| !candidate.is_empty())
|
||||
}
|
||||
|
||||
pub fn run_ai_model_stream<F>(request: AiModelStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: format!("api-{}", uuid::Uuid::new_v4()),
|
||||
});
|
||||
|
||||
let text = send_model_message(&request)?;
|
||||
|
||||
emit(AiAgentStreamEvent::TextDelta { text });
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<String, String> {
|
||||
let request = AiModelStreamRequest {
|
||||
provider: request.provider,
|
||||
model_id: request.model_id,
|
||||
message: "Reply with exactly OK.".into(),
|
||||
system_prompt: Some(
|
||||
"You are testing whether this model endpoint is reachable. Reply with exactly OK."
|
||||
.into(),
|
||||
),
|
||||
api_key_override: normalize_optional_string(request.api_key_override),
|
||||
};
|
||||
send_model_message(&request)
|
||||
}
|
||||
|
||||
fn send_model_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
match request.provider.kind {
|
||||
AiModelProviderKind::Anthropic => send_anthropic_message(request),
|
||||
_ => send_openai_compatible_message(request),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_openai_compatible_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
let endpoint = format!("{}/chat/completions", normalized_base_url(request)?);
|
||||
let mut messages = Vec::new();
|
||||
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
|
||||
messages.push(serde_json::json!({ "role": "system", "content": system_prompt }));
|
||||
}
|
||||
messages.push(serde_json::json!({ "role": "user", "content": request.message }));
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": request.model_id,
|
||||
"messages": messages,
|
||||
"stream": false
|
||||
});
|
||||
let json = send_json_request(request, endpoint, payload)?;
|
||||
extract_openai_text(&json)
|
||||
}
|
||||
|
||||
fn send_anthropic_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
let endpoint = format!("{}/messages", normalized_base_url(request)?);
|
||||
let mut payload = serde_json::json!({
|
||||
"model": request.model_id,
|
||||
"max_tokens": selected_max_tokens(request),
|
||||
"messages": [{ "role": "user", "content": request.message }]
|
||||
});
|
||||
|
||||
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
|
||||
payload["system"] = serde_json::Value::String(system_prompt.to_string());
|
||||
}
|
||||
|
||||
let json = send_json_request(request, endpoint, payload)?;
|
||||
extract_anthropic_text(&json)
|
||||
}
|
||||
|
||||
fn selected_max_tokens(request: &AiModelStreamRequest) -> u32 {
|
||||
request
|
||||
.provider
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.id == request.model_id)
|
||||
.and_then(|model| model.max_output_tokens)
|
||||
.unwrap_or(4096)
|
||||
}
|
||||
|
||||
fn normalized_base_url(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
let fallback = provider_default_base_url(&request.provider.kind).unwrap_or("");
|
||||
let base = request
|
||||
.provider
|
||||
.base_url
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
.unwrap_or(fallback)
|
||||
.trim_end_matches('/');
|
||||
if base.is_empty() {
|
||||
return Err("Custom API providers need a base URL.".into());
|
||||
}
|
||||
Ok(base.to_string())
|
||||
}
|
||||
|
||||
fn send_json_request(
|
||||
request: &AiModelStreamRequest,
|
||||
endpoint: String,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|error| format!("Failed to create HTTP client: {error}"))?;
|
||||
let builder = client.post(endpoint).json(&payload);
|
||||
let builder = apply_auth_headers(builder, request)?;
|
||||
let builder = apply_provider_headers(builder, request);
|
||||
let response = send_provider_request(builder)?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.map_err(|error| format!("Failed to read AI provider response: {error}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"AI provider returned {status}: {}",
|
||||
truncate_error(&text)
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|error| format!("Failed to parse AI provider response: {error}"))
|
||||
}
|
||||
|
||||
fn apply_auth_headers(
|
||||
builder: reqwest::blocking::RequestBuilder,
|
||||
request: &AiModelStreamRequest,
|
||||
) -> Result<reqwest::blocking::RequestBuilder, String> {
|
||||
let Some(api_key) = api_key_from_provider(request)? else {
|
||||
return Ok(builder);
|
||||
};
|
||||
|
||||
Ok(match request.provider.kind {
|
||||
AiModelProviderKind::Anthropic => builder.header("x-api-key", api_key),
|
||||
_ => builder.bearer_auth(api_key),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_provider_headers(
|
||||
mut builder: reqwest::blocking::RequestBuilder,
|
||||
request: &AiModelStreamRequest,
|
||||
) -> reqwest::blocking::RequestBuilder {
|
||||
if matches!(request.provider.kind, AiModelProviderKind::Anthropic) {
|
||||
builder = builder.header("anthropic-version", "2023-06-01");
|
||||
}
|
||||
for (key, value) in safe_custom_headers(request) {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
fn safe_custom_headers(request: &AiModelStreamRequest) -> Vec<(&String, &String)> {
|
||||
request
|
||||
.provider
|
||||
.headers
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|headers| headers.iter())
|
||||
.filter(|(key, value)| {
|
||||
!key.eq_ignore_ascii_case("authorization") && non_empty_option(Some(value)).is_some()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn send_provider_request(
|
||||
builder: reqwest::blocking::RequestBuilder,
|
||||
) -> Result<reqwest::blocking::Response, String> {
|
||||
builder
|
||||
.send()
|
||||
.map_err(|error| format!("AI provider request failed: {error}"))
|
||||
}
|
||||
|
||||
pub fn save_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
|
||||
let provider_id = normalize_secret_provider_id(&provider_id)?;
|
||||
let api_key = api_key.trim().to_string();
|
||||
if api_key.is_empty() {
|
||||
return Err("API key cannot be empty.".into());
|
||||
}
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.insert(provider_id, api_key);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
|
||||
pub fn delete_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
let provider_id = normalize_secret_provider_id(&provider_id)?;
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.remove(&provider_id);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
|
||||
fn normalize_secret_provider_id(provider_id: &str) -> Result<String, String> {
|
||||
let provider_id = provider_id.trim().to_ascii_lowercase();
|
||||
if provider_id.is_empty() {
|
||||
Err("Provider ID cannot be empty.".into())
|
||||
} else {
|
||||
Ok(provider_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn secrets_path() -> Result<std::path::PathBuf, String> {
|
||||
crate::settings::preferred_app_config_path("ai-provider-secrets.json")
|
||||
}
|
||||
|
||||
fn read_secrets_at(path: &Path) -> Result<AiProviderSecrets, String> {
|
||||
if !path.exists() {
|
||||
return Ok(AiProviderSecrets::default());
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|error| format!("Failed to read AI provider secrets: {error}"))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|error| format!("Failed to parse AI provider secrets: {error}"))
|
||||
}
|
||||
|
||||
fn write_secrets_at(path: &Path, secrets: &AiProviderSecrets) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("Failed to create AI provider secrets directory: {error}"))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(secrets)
|
||||
.map_err(|error| format!("Failed to serialize AI provider secrets: {error}"))?;
|
||||
write_secret_file(path, json)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|error| format!("Failed to open AI provider secrets file: {error}"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|error| format!("Failed to write AI provider secrets: {error}"))?;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("Failed to secure AI provider secrets file: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
|
||||
fs::write(path, content)
|
||||
.map_err(|error| format!("Failed to write AI provider secrets: {error}"))
|
||||
}
|
||||
|
||||
fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
let secrets = read_secrets_at(&secrets_path()?)?;
|
||||
let api_key = secrets
|
||||
.provider_api_keys
|
||||
.get(&request.provider.id)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
if api_key.is_none() {
|
||||
return Err(format!(
|
||||
"No local API key is saved for {}.",
|
||||
request.provider.name
|
||||
));
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
let Some(name) = request
|
||||
.provider
|
||||
.api_key_env_var
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
std::env::var(name)
|
||||
.map(Some)
|
||||
.map_err(|_| format!("Environment variable {name} is not set for this AI provider."))
|
||||
}
|
||||
|
||||
fn api_key_from_provider(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
if let Some(api_key) = request
|
||||
.api_key_override
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
.map(str::to_string)
|
||||
{
|
||||
return Ok(Some(api_key));
|
||||
}
|
||||
|
||||
match request.provider.api_key_storage {
|
||||
Some(AiModelApiKeyStorage::LocalFile) => api_key_from_local_file(request),
|
||||
Some(AiModelApiKeyStorage::Env) => api_key_from_env(request),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_openai_text(json: &serde_json::Value) -> Result<String, String> {
|
||||
json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.ok_or_else(|| "AI provider response did not include assistant text.".to_string())
|
||||
}
|
||||
|
||||
fn extract_anthropic_text(json: &serde_json::Value) -> Result<String, String> {
|
||||
let text = json["content"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|block| block["text"].as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if text.trim().is_empty() {
|
||||
Err("Anthropic response did not include assistant text.".into())
|
||||
} else {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_option(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn non_empty_str(value: &str) -> Option<&str> {
|
||||
non_empty_option(Some(value))
|
||||
}
|
||||
|
||||
fn truncate_error(value: &str) -> String {
|
||||
const MAX_ERROR_LENGTH: usize = 600;
|
||||
if value.len() <= MAX_ERROR_LENGTH {
|
||||
return value.to_string();
|
||||
}
|
||||
format!("{}...", &value[..MAX_ERROR_LENGTH])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn capabilities() -> AiModelCapabilities {
|
||||
AiModelCapabilities {
|
||||
streaming: true,
|
||||
tools: false,
|
||||
vision: false,
|
||||
json_mode: true,
|
||||
reasoning: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn model(id: &str) -> AiModelDefinition {
|
||||
AiModelDefinition {
|
||||
id: id.into(),
|
||||
display_name: Some(" Demo Model ".into()),
|
||||
context_window: Some(128_000),
|
||||
max_output_tokens: Some(8192),
|
||||
capabilities: capabilities(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(kind: AiModelProviderKind) -> AiModelProvider {
|
||||
AiModelProvider {
|
||||
id: " Demo ".into(),
|
||||
name: " Demo Provider ".into(),
|
||||
kind,
|
||||
base_url: Some(" https://example.com/v1/ ".into()),
|
||||
api_key_storage: None,
|
||||
api_key_env_var: Some(" DEMO_API_KEY ".into()),
|
||||
headers: None,
|
||||
models: vec![model(" demo-model "), model(" ")],
|
||||
}
|
||||
}
|
||||
|
||||
fn request(provider: AiModelProvider) -> AiModelStreamRequest {
|
||||
AiModelStreamRequest {
|
||||
provider,
|
||||
model_id: "demo-model".into(),
|
||||
message: "Hello".into(),
|
||||
system_prompt: Some(" Be concise. ".into()),
|
||||
api_key_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_providers_trims_values_and_filters_invalid_entries() {
|
||||
let invalid = AiModelProvider {
|
||||
id: " ".into(),
|
||||
name: "Missing".into(),
|
||||
kind: AiModelProviderKind::OpenAiCompatible,
|
||||
base_url: None,
|
||||
api_key_storage: None,
|
||||
api_key_env_var: None,
|
||||
headers: None,
|
||||
models: vec![model("ignored")],
|
||||
};
|
||||
|
||||
let providers = normalize_ai_model_providers(Some(vec![
|
||||
invalid,
|
||||
provider(AiModelProviderKind::OpenAiCompatible),
|
||||
]))
|
||||
.expect("valid provider should remain");
|
||||
|
||||
assert_eq!(providers.len(), 1);
|
||||
let normalized = &providers[0];
|
||||
assert_eq!(normalized.id, "demo");
|
||||
assert_eq!(normalized.name, "Demo Provider");
|
||||
assert_eq!(
|
||||
normalized.base_url.as_deref(),
|
||||
Some("https://example.com/v1/")
|
||||
);
|
||||
assert_eq!(normalized.api_key_env_var.as_deref(), Some("DEMO_API_KEY"));
|
||||
assert_eq!(normalized.api_key_storage, Some(AiModelApiKeyStorage::Env));
|
||||
assert_eq!(normalized.models.len(), 1);
|
||||
assert_eq!(normalized.models[0].id, "demo-model");
|
||||
assert_eq!(
|
||||
normalized.models[0].display_name.as_deref(),
|
||||
Some("Demo Model")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_providers_returns_none_for_missing_or_empty_sets() {
|
||||
assert_eq!(normalize_ai_model_providers(None), None);
|
||||
assert_eq!(normalize_ai_model_providers(Some(Vec::new())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_request_helpers_resolve_defaults_and_safe_headers() {
|
||||
let mut provider = provider(AiModelProviderKind::Anthropic);
|
||||
provider.base_url = None;
|
||||
provider.headers = Some(BTreeMap::from([
|
||||
("Authorization".into(), "ignored".into()),
|
||||
("X-Demo".into(), "demo".into()),
|
||||
("X-Blank".into(), " ".into()),
|
||||
]));
|
||||
provider.models = vec![model("demo-model")];
|
||||
let request = request(provider);
|
||||
let headers = safe_custom_headers(&request)
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
normalized_base_url(&request).unwrap(),
|
||||
"https://api.anthropic.com/v1"
|
||||
);
|
||||
assert_eq!(selected_max_tokens(&request), 8192);
|
||||
assert_eq!(headers, vec![("X-Demo", "demo")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_provider_catalog_supplies_runtime_base_urls() {
|
||||
assert_eq!(
|
||||
provider_default_base_url(&AiModelProviderKind::OpenAi),
|
||||
Some("https://api.openai.com/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_default_base_url(&AiModelProviderKind::Ollama),
|
||||
Some("http://localhost:11434/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_default_base_url(&AiModelProviderKind::OpenAiCompatible),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_provider_requires_base_url() {
|
||||
let mut provider = provider(AiModelProviderKind::OpenAiCompatible);
|
||||
provider.base_url = Some(" ".into());
|
||||
|
||||
assert_eq!(
|
||||
normalized_base_url(&request(provider)).unwrap_err(),
|
||||
"Custom API providers need a base URL.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_provider_text_payloads_and_reports_empty_responses() {
|
||||
let openai = json!({
|
||||
"choices": [{ "message": { "content": "Hello from OpenAI" } }]
|
||||
});
|
||||
let anthropic = json!({
|
||||
"content": [
|
||||
{ "text": "Hello " },
|
||||
{ "text": "from Anthropic" }
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(extract_openai_text(&openai).unwrap(), "Hello from OpenAI");
|
||||
assert_eq!(
|
||||
extract_anthropic_text(&anthropic).unwrap(),
|
||||
"Hello from Anthropic"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_openai_text(&json!({ "choices": [] })).unwrap_err(),
|
||||
"AI provider response did not include assistant text.",
|
||||
);
|
||||
assert_eq!(
|
||||
extract_anthropic_text(&json!({ "content": [{ "type": "thinking" }] })).unwrap_err(),
|
||||
"Anthropic response did not include assistant text.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_reads_and_validates_local_provider_secrets() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nested/secrets.json");
|
||||
let secrets = AiProviderSecrets {
|
||||
provider_api_keys: BTreeMap::from([("demo".into(), "secret".into())]),
|
||||
};
|
||||
|
||||
write_secrets_at(&path, &secrets).unwrap();
|
||||
|
||||
assert_eq!(read_secrets_at(&path).unwrap(), secrets);
|
||||
assert_eq!(
|
||||
read_secrets_at(&dir.path().join("missing.json")).unwrap(),
|
||||
AiProviderSecrets::default()
|
||||
);
|
||||
assert_eq!(normalize_secret_provider_id(" Demo ").unwrap(), "demo");
|
||||
assert_eq!(
|
||||
normalize_secret_provider_id(" ").unwrap_err(),
|
||||
"Provider ID cannot be empty.",
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_provider_errors_without_touching_short_errors() {
|
||||
let short = "provider unavailable";
|
||||
let long = "x".repeat(605);
|
||||
|
||||
assert_eq!(truncate_error(short), short);
|
||||
assert_eq!(truncate_error(&long).len(), 603);
|
||||
assert!(truncate_error(&long).ends_with("..."));
|
||||
}
|
||||
}
|
||||
@@ -387,6 +387,7 @@ fn build_claude_command(
|
||||
cwd: Option<&str>,
|
||||
) -> std::process::Command {
|
||||
let mut cmd = crate::hidden_command(bin);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, bin);
|
||||
cmd.args(args)
|
||||
.env_remove("CLAUDECODE") // prevent "nested session" guard
|
||||
.stdin(Stdio::null())
|
||||
@@ -1527,7 +1528,7 @@ mod tests {
|
||||
let mut events = vec![];
|
||||
let result = run_claude_subprocess(&fake_bin, &[], None, &mut |e| events.push(e));
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Failed to spawn"));
|
||||
assert!(result.unwrap_err().contains("Failed to start claude"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent};
|
||||
use serde::Deserialize;
|
||||
use std::ffi::OsString;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitStatus};
|
||||
@@ -39,7 +40,9 @@ pub(crate) fn mcp_server_path_string() -> Result<String, String> {
|
||||
}
|
||||
|
||||
pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
crate::hidden_command(binary)
|
||||
let mut command = crate::hidden_command(binary);
|
||||
configure_agent_command_environment(&mut command, binary);
|
||||
command
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
@@ -47,6 +50,68 @@ pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn configure_agent_command_environment(command: &mut Command, binary: &Path) {
|
||||
if let Some(path) = expanded_agent_path(binary) {
|
||||
command.env("PATH", path);
|
||||
}
|
||||
}
|
||||
|
||||
fn expanded_agent_path(binary: &Path) -> Option<OsString> {
|
||||
let mut paths = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
for candidate in agent_path_candidates(binary) {
|
||||
push_unique_path(&mut paths, candidate);
|
||||
}
|
||||
|
||||
std::env::join_paths(paths).ok()
|
||||
}
|
||||
|
||||
fn agent_path_candidates(binary: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Some(parent) = binary
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
candidates.push(parent.to_path_buf());
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.extend([
|
||||
home.join(".local/bin"),
|
||||
home.join(".local/share/mise/shims"),
|
||||
home.join(".asdf/shims"),
|
||||
home.join(".npm-global/bin"),
|
||||
home.join(".npm/bin"),
|
||||
home.join(".bun/bin"),
|
||||
home.join(".linuxbrew/bin"),
|
||||
home.join("AppData/Roaming/npm"),
|
||||
home.join("AppData/Local/pnpm"),
|
||||
home.join("scoop/shims"),
|
||||
]);
|
||||
}
|
||||
|
||||
candidates.extend([
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
|
||||
]);
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn push_unique_path(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
|
||||
if candidate.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
if paths.iter().any(|path| path == &candidate) {
|
||||
return;
|
||||
}
|
||||
paths.push(candidate);
|
||||
}
|
||||
|
||||
pub(crate) fn find_executable_binary_candidate(
|
||||
candidates: Vec<PathBuf>,
|
||||
agent_label: &str,
|
||||
@@ -134,7 +199,7 @@ where
|
||||
{
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn {process_name}: {error}"))?;
|
||||
.map_err(|error| format_spawn_error(process_name, &error))?;
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
@@ -166,6 +231,16 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
fn format_spawn_error(process_name: &str, error: &std::io::Error) -> String {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
return format!(
|
||||
"Failed to start {process_name}: the CLI or one of its runtime dependencies was not found. If it was installed with Homebrew, make sure /opt/homebrew/bin or /usr/local/bin contains the CLI and Node.js, then restart Tolaria. Details: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
format!("Failed to spawn {process_name}: {error}")
|
||||
}
|
||||
|
||||
pub(crate) fn run_ai_agent_json_stream<F>(
|
||||
command: Command,
|
||||
process_name: &'static str,
|
||||
@@ -229,6 +304,31 @@ mod tests {
|
||||
assert!(error.contains("broken pipe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_command_environment_keeps_homebrew_shims_available() {
|
||||
let mut command = Command::new("/opt/homebrew/bin/codex");
|
||||
configure_agent_command_environment(&mut command, Path::new("/opt/homebrew/bin/codex"));
|
||||
let path = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == std::ffi::OsStr::new("PATH"))
|
||||
.and_then(|(_, value)| value)
|
||||
.expect("PATH should be set");
|
||||
let paths = std::env::split_paths(path).collect::<Vec<_>>();
|
||||
|
||||
assert!(paths.contains(&PathBuf::from("/opt/homebrew/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/usr/local/bin")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_not_found_errors_explain_gui_path_runtime_dependencies() {
|
||||
let error = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
|
||||
let message = format_spawn_error("codex", &error);
|
||||
|
||||
assert!(message.contains("Failed to start codex"));
|
||||
assert!(message.contains("/opt/homebrew/bin"));
|
||||
assert!(message.contains("Node.js"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn executable_binary_candidate_skips_unusable_file_when_later_candidate_works() {
|
||||
|
||||
@@ -39,13 +39,21 @@ fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Option<PathBuf> {
|
||||
crate::hidden_command("which")
|
||||
crate::hidden_command(codex_path_lookup_command())
|
||||
.arg("codex")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| path_from_successful_output(&output))
|
||||
}
|
||||
|
||||
fn codex_path_lookup_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"where"
|
||||
} else {
|
||||
"which"
|
||||
}
|
||||
}
|
||||
|
||||
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
|
||||
user_shell_candidates()
|
||||
.into_iter()
|
||||
@@ -102,13 +110,27 @@ fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".codex/bin/codex"),
|
||||
home.join(".codex/bin/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".npm-global/bin/codex"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
home.join(".npm/bin/codex"),
|
||||
home.join(".npm/bin/codex.cmd"),
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join(".bun/bin/codex"),
|
||||
home.join(".bun/bin/codex.exe"),
|
||||
home.join(".linuxbrew/bin/codex"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
PathBuf::from("/home/linuxbrew/.linuxbrew/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
@@ -149,9 +171,15 @@ fn run_agent_stream_with_binary<F>(
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let args = build_codex_args(&request)?;
|
||||
let last_message_dir = tempfile::Builder::new()
|
||||
.prefix("tolaria-codex-last-message-")
|
||||
.tempdir()
|
||||
.map_err(|error| format!("Failed to create Codex output directory: {error}"))?;
|
||||
let last_message_path = last_message_dir.path().join("last-message.txt");
|
||||
let args = build_codex_args(&request, Some(&last_message_path))?;
|
||||
let prompt = build_codex_prompt(&request);
|
||||
let command = build_codex_command(binary, args, prompt, &request.vault_path);
|
||||
let emit = with_codex_last_message_fallback(emit, last_message_path);
|
||||
|
||||
crate::cli_agent_runtime::run_ai_agent_json_stream(
|
||||
command,
|
||||
@@ -163,6 +191,33 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
fn with_codex_last_message_fallback<F>(
|
||||
mut emit: F,
|
||||
last_message_path: PathBuf,
|
||||
) -> impl FnMut(AiAgentStreamEvent)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let mut text_emitted = false;
|
||||
|
||||
move |event| {
|
||||
match &event {
|
||||
AiAgentStreamEvent::TextDelta { text } if !text.trim().is_empty() => {
|
||||
text_emitted = true;
|
||||
}
|
||||
AiAgentStreamEvent::Done if !text_emitted => {
|
||||
if let Some(text) = read_codex_last_message(&last_message_path) {
|
||||
text_emitted = true;
|
||||
emit(AiAgentStreamEvent::TextDelta { text });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_codex_command(
|
||||
binary: &Path,
|
||||
args: Vec<String>,
|
||||
@@ -170,19 +225,25 @@ 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)
|
||||
.current_dir(vault_path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
command
|
||||
}
|
||||
|
||||
fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
fn build_codex_args(
|
||||
request: &AgentStreamRequest,
|
||||
last_message_path: Option<&Path>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
||||
let node_path = crate::mcp::find_node()?;
|
||||
|
||||
Ok(vec![
|
||||
let mut args = vec![
|
||||
"--sandbox".into(),
|
||||
codex_sandbox(request.permission_mode).into(),
|
||||
"--ask-for-approval".into(),
|
||||
@@ -192,15 +253,43 @@ fn build_codex_args(request: &AgentStreamRequest) -> Result<Vec<String>, String>
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
codex_config_string("mcp_servers.tolaria.command", &node_path.to_string_lossy()),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
codex_config_string_list("mcp_servers.tolaria.args", &[mcp_server_path.as_str()]),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
])
|
||||
codex_mcp_env_config(&request.vault_path),
|
||||
];
|
||||
|
||||
if let Some(path) = last_message_path {
|
||||
args.push("--output-last-message".into());
|
||||
args.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn codex_config_string(key: &str, value: &str) -> String {
|
||||
format!(r#"{key}="{}""#, toml_escape(value))
|
||||
}
|
||||
|
||||
fn codex_config_string_list(key: &str, values: &[&str]) -> String {
|
||||
let values = values
|
||||
.iter()
|
||||
.map(|value| format!(r#""{}""#, toml_escape(value)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!("{key}=[{values}]")
|
||||
}
|
||||
|
||||
fn codex_mcp_env_config(vault_path: &str) -> String {
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}",WS_UI_PORT="9711"}}"#,
|
||||
toml_escape(vault_path)
|
||||
)
|
||||
}
|
||||
|
||||
fn toml_escape(value: &str) -> String {
|
||||
value.replace('\\', r#"\\"#).replace('"', r#"\""#)
|
||||
}
|
||||
|
||||
fn codex_sandbox(permission_mode: crate::ai_agents::AiAgentPermissionMode) -> &'static str {
|
||||
@@ -270,6 +359,7 @@ where
|
||||
});
|
||||
}
|
||||
}
|
||||
"mcp_tool_call" => emit_codex_mcp_tool_event(item, item_id, completed, emit),
|
||||
"agent_message" if completed => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
@@ -281,6 +371,56 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_codex_mcp_tool_event<F>(
|
||||
item: &serde_json::Value,
|
||||
item_id: &str,
|
||||
completed: bool,
|
||||
emit: &mut F,
|
||||
) where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
if completed {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: item_id.to_string(),
|
||||
output: codex_tool_output(item),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let tool_name = item["tool"].as_str().unwrap_or("MCP tool");
|
||||
let input = json_field_to_string(&item["arguments"]);
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: tool_name.to_string(),
|
||||
tool_id: item_id.to_string(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
fn codex_tool_output(item: &serde_json::Value) -> Option<String> {
|
||||
item["error"]["message"]
|
||||
.as_str()
|
||||
.map(|message| format!("Error: {message}"))
|
||||
.or_else(|| json_field_to_string(&item["result"]))
|
||||
}
|
||||
|
||||
fn json_field_to_string(value: &serde_json::Value) -> Option<String> {
|
||||
if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.or_else(|| Some(value.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_codex_last_message(path: &Path) -> Option<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|text| text.trim().to_string())
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
@@ -398,12 +538,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_safe_default_permissions() {
|
||||
if let Ok(args) = build_codex_args(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
}) {
|
||||
if let Ok(args) = build_codex_args(
|
||||
&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
},
|
||||
None,
|
||||
) {
|
||||
assert_eq!(args[4], "exec");
|
||||
assert_codex_permission_contract(&args, AiAgentPermissionMode::Safe);
|
||||
assert!(args.contains(&"--json".to_string()));
|
||||
@@ -413,16 +556,67 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn codex_power_user_keeps_workspace_write_without_dangerous_bypass() {
|
||||
if let Ok(args) = build_codex_args(&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::PowerUser,
|
||||
}) {
|
||||
if let Ok(args) = build_codex_args(
|
||||
&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::PowerUser,
|
||||
},
|
||||
None,
|
||||
) {
|
||||
assert_codex_permission_contract(&args, AiAgentPermissionMode::PowerUser);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_can_request_last_message_output_file() {
|
||||
if let Ok(args) = build_codex_args(
|
||||
&AgentStreamRequest {
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
},
|
||||
Some(Path::new("/tmp/tolaria-codex-last-message.txt")),
|
||||
) {
|
||||
assert!(args.windows(2).any(|window| window
|
||||
== [
|
||||
"--output-last-message",
|
||||
"/tmp/tolaria-codex-last-message.txt",
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_args_uses_resolved_mcp_node_and_ui_bridge_env() {
|
||||
let args = build_codex_args(
|
||||
&AgentStreamRequest {
|
||||
message: "Read [[Test note]]".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
permission_mode: AiAgentPermissionMode::Safe,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let command_override = args
|
||||
.iter()
|
||||
.find(|arg| arg.starts_with("mcp_servers.tolaria.command="))
|
||||
.expect("Codex should receive a transient Tolaria MCP command");
|
||||
|
||||
assert!(
|
||||
!command_override.ends_with(r#""node""#),
|
||||
"Codex MCP command should use Tolaria's resolved Node path, got {command_override}"
|
||||
);
|
||||
assert!(
|
||||
command_override.contains('/'),
|
||||
"Codex MCP command should be an absolute Node path, got {command_override}"
|
||||
);
|
||||
assert!(args.iter().any(|arg| arg.contains(r#"WS_UI_PORT="9711""#)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_keeps_agent_process_contract() {
|
||||
let binary = PathBuf::from("codex");
|
||||
@@ -442,6 +636,28 @@ mod tests {
|
||||
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_command_extends_path_with_resolved_homebrew_bin() {
|
||||
let binary = PathBuf::from("/opt/homebrew/bin/codex");
|
||||
let command = build_codex_command(
|
||||
&binary,
|
||||
vec!["exec".to_string(), "--json".to_string()],
|
||||
"Summarize".into(),
|
||||
"/tmp/vault",
|
||||
);
|
||||
let path_value = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == OsStr::new("PATH"))
|
||||
.and_then(|(_, value)| value)
|
||||
.expect("PATH should be set");
|
||||
let paths = std::env::split_paths(path_value).collect::<Vec<_>>();
|
||||
|
||||
assert!(
|
||||
paths.contains(&PathBuf::from("/opt/homebrew/bin")),
|
||||
"PATH should include the resolved Codex binary directory, got {paths:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() {
|
||||
@@ -455,6 +671,55 @@ printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_messa
|
||||
assert_codex_text_flow(&events, "thread_1", "Done");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_uses_last_message_file_when_stream_has_no_text() {
|
||||
let (thread_id, events) = run_codex_script(
|
||||
r#"last_message=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "--output-last-message" ]; then
|
||||
shift
|
||||
last_message="$1"
|
||||
fi
|
||||
shift
|
||||
done
|
||||
printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
|
||||
printf '%s' 'Recovered final answer' > "$last_message"
|
||||
"#,
|
||||
);
|
||||
|
||||
assert_eq!(thread_id, "thread_1");
|
||||
assert_codex_text_flow(&events, "thread_1", "Recovered final answer");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_does_not_duplicate_last_message_file_after_text_event() {
|
||||
let (thread_id, events) = run_codex_script(
|
||||
r#"last_message=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "--output-last-message" ]; then
|
||||
shift
|
||||
last_message="$1"
|
||||
fi
|
||||
shift
|
||||
done
|
||||
printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}'
|
||||
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Streamed answer"}}'
|
||||
printf '%s' 'Recovered final answer' > "$last_message"
|
||||
"#,
|
||||
);
|
||||
|
||||
let text_events = events
|
||||
.iter()
|
||||
.filter(|event| matches!(event, AiAgentStreamEvent::TextDelta { .. }))
|
||||
.count();
|
||||
|
||||
assert_eq!(thread_id, "thread_1");
|
||||
assert_eq!(text_events, 1);
|
||||
assert_codex_text_flow(&events, "thread_1", "Streamed answer");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_reports_nonzero_exit_errors() {
|
||||
@@ -473,6 +738,88 @@ exit 2
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open() {
|
||||
use std::io::Read;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let mut child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.arg("codex_stdin_probe_parent_child")
|
||||
.arg("--ignored")
|
||||
.arg("--nocapture")
|
||||
.env("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD", "1")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let child_stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let mut stderr = child.stderr.take().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
drop(child_stdin);
|
||||
panic!("Codex stdin probe child timed out");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
|
||||
drop(child_stdin);
|
||||
let mut stdout_text = String::new();
|
||||
let mut stderr_text = String::new();
|
||||
stdout.read_to_string(&mut stdout_text).unwrap();
|
||||
stderr.read_to_string(&mut stderr_text).unwrap();
|
||||
|
||||
assert!(
|
||||
status.success(),
|
||||
"Codex stdin probe child failed with {status}\nstdout:\n{stdout_text}\nstderr:\n{stderr_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[ignore = "spawned by run_codex_agent_stream_closes_stdin_even_when_parent_stdin_pipe_is_open"]
|
||||
#[test]
|
||||
fn codex_stdin_probe_parent_child() {
|
||||
if std::env::var_os("TOLARIA_CODEX_STDIN_PROBE_PARENT_CHILD").is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = tempfile::tempdir().unwrap();
|
||||
let binary = executable_script(
|
||||
dir.path(),
|
||||
"codex",
|
||||
r#"stdin="$(cat)"
|
||||
if [ -n "$stdin" ]; then
|
||||
echo "stdin was not closed" >&2
|
||||
exit 9
|
||||
fi
|
||||
printf '%s\n' '{"type":"thread.started","thread_id":"stdin-ok"}'
|
||||
printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"stdin closed"}}'
|
||||
"#,
|
||||
);
|
||||
let mut events = Vec::new();
|
||||
let result = run_agent_stream_with_binary(
|
||||
&binary,
|
||||
codex_request(vault.path(), AiAgentPermissionMode::Safe),
|
||||
|event| events.push(event),
|
||||
);
|
||||
|
||||
assert_eq!(result.unwrap(), "stdin-ok");
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "stdin closed"
|
||||
)));
|
||||
assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_supported_macos_installs() {
|
||||
let home = PathBuf::from("/Users/alex");
|
||||
@@ -514,6 +861,34 @@ exit 2
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_windows_npm_and_toolchain_shims() {
|
||||
let home = PathBuf::from("C:/Users/alex");
|
||||
let candidates = codex_binary_candidates_for_home(&home);
|
||||
let expected = [
|
||||
home.join(".local/bin/codex.exe"),
|
||||
home.join(".local/share/mise/shims/codex.exe"),
|
||||
home.join(".asdf/shims/codex.exe"),
|
||||
home.join(".npm-global/bin/codex.cmd"),
|
||||
home.join(".npm-global/bin/codex.exe"),
|
||||
home.join(".npm/bin/codex.cmd"),
|
||||
home.join(".npm/bin/codex.exe"),
|
||||
home.join("AppData/Roaming/npm/codex.cmd"),
|
||||
home.join("AppData/Roaming/npm/codex.exe"),
|
||||
home.join("AppData/Local/pnpm/codex.cmd"),
|
||||
home.join("AppData/Local/pnpm/codex.exe"),
|
||||
home.join("scoop/shims/codex.exe"),
|
||||
];
|
||||
|
||||
for candidate in expected {
|
||||
assert!(
|
||||
candidates.contains(&candidate),
|
||||
"missing {}",
|
||||
candidate.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_binary_candidates_include_nvm_managed_node_installs() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
@@ -609,6 +984,51 @@ exit 2
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_mcp_tool_call_maps_to_tool_events() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({
|
||||
"type": "item.started",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "mcp_tool_call",
|
||||
"server": "tolaria",
|
||||
"tool": "search_notes",
|
||||
"arguments": { "query": "meeting", "limit": 5 },
|
||||
"status": "in_progress"
|
||||
}
|
||||
});
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "mcp_tool_call",
|
||||
"server": "tolaria",
|
||||
"tool": "search_notes",
|
||||
"arguments": { "query": "meeting", "limit": 5 },
|
||||
"result": [{ "title": "Meeting notes" }],
|
||||
"status": "completed"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&started, &mut |event| events.push(event));
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input }
|
||||
if tool_name == "search_notes"
|
||||
&& tool_id == "item_1"
|
||||
&& input.as_deref().is_some_and(|value| value.contains("meeting"))
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "item_1"
|
||||
&& output.as_deref().is_some_and(|value| value.contains("Meeting notes"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_agent_message_maps_to_text_delta() {
|
||||
let mut events = Vec::new();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#[cfg(desktop)]
|
||||
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
|
||||
#[cfg(desktop)]
|
||||
use crate::ai_models::{AiModelProviderTestRequest, AiModelStreamRequest};
|
||||
use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus};
|
||||
use crate::vault::VaultAiGuidanceStatus;
|
||||
|
||||
@@ -81,14 +83,54 @@ define_desktop_stream_command!(
|
||||
crate::claude_cli::run_chat_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn normalize_agent_request(mut request: AiAgentStreamRequest) -> AiAgentStreamRequest {
|
||||
request.vault_path = expand_tilde(&request.vault_path).into_owned();
|
||||
request
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn run_normalized_ai_agent_stream(
|
||||
request: AiAgentStreamRequest,
|
||||
emitter: StreamEmitter<crate::ai_agents::AiAgentStreamEvent>,
|
||||
) -> Result<String, String> {
|
||||
crate::ai_agents::run_ai_agent_stream(normalize_agent_request(request), emitter)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_agent,
|
||||
AiAgentStreamRequest,
|
||||
"ai-agent-stream",
|
||||
crate::ai_agents::run_ai_agent_stream
|
||||
run_normalized_ai_agent_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_model,
|
||||
AiModelStreamRequest,
|
||||
"ai-model-stream",
|
||||
crate::ai_models::run_ai_model_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
|
||||
crate::ai_models::save_provider_api_key(provider_id, api_key)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn delete_ai_model_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
crate::ai_models::delete_provider_api_key(provider_id)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<String, String> {
|
||||
crate::ai_models::test_ai_model_provider(request)
|
||||
}
|
||||
|
||||
// ── Claude CLI (mobile stubs) ───────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -145,11 +187,84 @@ pub async fn stream_ai_agent(
|
||||
Err("CLI AI agents are not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_model(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_request: crate::ai_models::AiModelStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Direct AI model chat is not available in this mobile build yet.".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn save_ai_model_provider_api_key(
|
||||
_provider_id: String,
|
||||
_api_key: String,
|
||||
) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn test_ai_model_provider(
|
||||
_request: crate::ai_models::AiModelProviderTestRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Direct AI model tests are not available in this mobile build yet.".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vault::AiGuidanceFileState;
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn normalize_agent_request_expands_tilde_in_vault_path() {
|
||||
use crate::ai_agents::AiAgentId;
|
||||
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let request = AiAgentStreamRequest {
|
||||
agent: AiAgentId::ClaudeCode,
|
||||
message: "hi".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "~/Vaults/content".into(),
|
||||
permission_mode: None,
|
||||
};
|
||||
|
||||
let normalized = normalize_agent_request(request);
|
||||
|
||||
assert_eq!(
|
||||
normalized.vault_path,
|
||||
format!("{}/Vaults/content", home.display()),
|
||||
"vault_path must be tilde-expanded so spawned agents can chdir into it",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn normalize_agent_request_leaves_absolute_vault_path_untouched() {
|
||||
use crate::ai_agents::AiAgentId;
|
||||
|
||||
let request = AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "hi".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/Users/example/vault".into(),
|
||||
permission_mode: None,
|
||||
};
|
||||
|
||||
let normalized = normalize_agent_request(request);
|
||||
|
||||
assert_eq!(normalized.vault_path, "/Users/example/vault");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guidance_commands_report_and_restore_vault_guidance_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -214,7 +214,7 @@ fn has_tolaria_vault_marker(path: &std::path::Path) -> bool {
|
||||
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
validate_git_init_target(&vault_path)?;
|
||||
crate::git::init_repo(&vault_path)
|
||||
crate::git::init_repo(std::path::Path::new(vault_path.as_ref()))
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
|
||||
167
src-tauri/src/commands/memory.rs
Normal file
167
src-tauri/src/commands/memory.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use serde::Serialize;
|
||||
use std::process::Command;
|
||||
|
||||
const WEBKIT_AUX_PID_WINDOW: u32 = 512;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessMemoryEntry {
|
||||
pub pid: u32,
|
||||
pub parent_pid: u32,
|
||||
pub rss_bytes: u64,
|
||||
pub role: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessMemorySnapshot {
|
||||
pub current_pid: u32,
|
||||
pub total_rss_bytes: u64,
|
||||
pub entries: Vec<ProcessMemoryEntry>,
|
||||
}
|
||||
|
||||
struct ProcessRow {
|
||||
pid: u32,
|
||||
parent_pid: u32,
|
||||
rss_kib: u64,
|
||||
command: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_process_memory_snapshot() -> Result<ProcessMemorySnapshot, String> {
|
||||
let current_pid = std::process::id();
|
||||
let entries = collect_related_process_memory(current_pid)?;
|
||||
let total_rss_bytes = entries.iter().map(|entry| entry.rss_bytes).sum();
|
||||
|
||||
Ok(ProcessMemorySnapshot {
|
||||
current_pid,
|
||||
total_rss_bytes,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_related_process_memory(current_pid: u32) -> Result<Vec<ProcessMemoryEntry>, String> {
|
||||
let rows = read_process_rows()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| related_process_entry(row, current_pid))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn related_process_entry(row: ProcessRow, current_pid: u32) -> Option<ProcessMemoryEntry> {
|
||||
let role = classify_related_process(&row, current_pid)?;
|
||||
Some(ProcessMemoryEntry {
|
||||
pid: row.pid,
|
||||
parent_pid: row.parent_pid,
|
||||
rss_bytes: row.rss_kib.saturating_mul(1024),
|
||||
role,
|
||||
command: row.command,
|
||||
})
|
||||
}
|
||||
|
||||
fn classify_related_process(row: &ProcessRow, current_pid: u32) -> Option<String> {
|
||||
if row.pid == current_pid {
|
||||
return Some("app".to_string());
|
||||
}
|
||||
if !is_nearby_webkit_auxiliary(row, current_pid) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if row.command.contains("WebKit.WebContent") {
|
||||
return Some("webkit-webcontent".to_string());
|
||||
}
|
||||
if row.command.contains("WebKit.GPU") {
|
||||
return Some("webkit-gpu".to_string());
|
||||
}
|
||||
if row.command.contains("WebKit.Networking") {
|
||||
return Some("webkit-networking".to_string());
|
||||
}
|
||||
Some("webkit".to_string())
|
||||
}
|
||||
|
||||
fn is_nearby_webkit_auxiliary(row: &ProcessRow, current_pid: u32) -> bool {
|
||||
row.pid > current_pid
|
||||
&& row.pid.saturating_sub(current_pid) <= WEBKIT_AUX_PID_WINDOW
|
||||
&& row.command.contains("com.apple.WebKit.")
|
||||
}
|
||||
|
||||
fn parse_process_row(line: &str) -> Option<ProcessRow> {
|
||||
let mut fields = line.split_whitespace();
|
||||
let pid = fields.next()?.parse().ok()?;
|
||||
let parent_pid = fields.next()?.parse().ok()?;
|
||||
let rss_kib = fields.next()?.parse().ok()?;
|
||||
let command = fields.collect::<Vec<_>>().join(" ");
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ProcessRow {
|
||||
pid,
|
||||
parent_pid,
|
||||
rss_kib,
|
||||
command,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
|
||||
let output = Command::new("ps")
|
||||
.args(["-axo", "pid=,ppid=,rss=,command="])
|
||||
.output()
|
||||
.map_err(|error| format!("Failed to sample process memory: {error}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("Failed to sample process memory with ps".to_string());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout.lines().filter_map(parse_process_row).collect())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
|
||||
Err("Process memory snapshots are only implemented on Unix platforms".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_ps_rows_with_spaced_commands() {
|
||||
let row = parse_process_row(" 42 1 1024 /System/WebKit WebContent").unwrap();
|
||||
|
||||
assert_eq!(row.pid, 42);
|
||||
assert_eq!(row.parent_pid, 1);
|
||||
assert_eq!(row.rss_kib, 1024);
|
||||
assert_eq!(row.command, "/System/WebKit WebContent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_nearby_webkit_auxiliaries() {
|
||||
let row = ProcessRow {
|
||||
pid: 120,
|
||||
parent_pid: 1,
|
||||
rss_kib: 10,
|
||||
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
classify_related_process(&row, 100),
|
||||
Some("webkit-webcontent".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unrelated_webkit_auxiliaries() {
|
||||
let row = ProcessRow {
|
||||
pid: 900,
|
||||
parent_pid: 1,
|
||||
rss_kib: 10,
|
||||
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(classify_related_process(&row, 100), None);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ mod folders;
|
||||
mod git;
|
||||
pub mod git_clone;
|
||||
mod git_connect;
|
||||
mod memory;
|
||||
mod system;
|
||||
mod vault;
|
||||
mod version;
|
||||
@@ -15,6 +16,7 @@ pub use delete::*;
|
||||
pub use folders::*;
|
||||
pub use git::*;
|
||||
pub use git_connect::*;
|
||||
pub use memory::*;
|
||||
pub use system::*;
|
||||
pub use vault::*;
|
||||
pub use version::*;
|
||||
|
||||
@@ -147,11 +147,23 @@ fn clipboard_command() -> Command {
|
||||
crate::hidden_command("pbcopy")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
crate::hidden_command("pbpaste")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_command() -> Command {
|
||||
crate::hidden_command("clip.exe")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("powershell.exe");
|
||||
command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
@@ -162,6 +174,16 @@ fn clipboard_command() -> Command {
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))]
|
||||
fn clipboard_read_command() -> Command {
|
||||
let mut command = crate::hidden_command("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn clipboard_failure_message(stderr: &[u8]) -> String {
|
||||
let message = String::from_utf8_lossy(stderr).trim().to_string();
|
||||
@@ -200,12 +222,33 @@ fn write_native_clipboard(mut command: Command, text: &str) -> Result<(), String
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn read_native_clipboard(mut command: Command) -> Result<String, String> {
|
||||
let output = command
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to read native clipboard text: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(clipboard_failure_message(&output.stderr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn copy_text_to_clipboard(text: String) -> Result<(), String> {
|
||||
write_native_clipboard(clipboard_command(), &text)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn read_text_from_clipboard() -> Result<String, String> {
|
||||
read_native_clipboard(clipboard_read_command())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(
|
||||
@@ -254,6 +297,12 @@ pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn read_text_from_clipboard() -> Result<String, String> {
|
||||
Err("Clipboard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn sync_mcp_bridge_vault(_vault_path: Option<String>) -> Result<String, String> {
|
||||
|
||||
@@ -271,8 +271,12 @@ pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
|
||||
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
|
||||
pub async fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -348,8 +352,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_and_listing_commands_use_expanded_vault_root() {
|
||||
#[tokio::test]
|
||||
async fn folder_and_listing_commands_use_expanded_vault_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = vault_root(&dir);
|
||||
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
|
||||
@@ -364,7 +368,7 @@ mod tests {
|
||||
assert!(entries.iter().any(|entry| entry.filename == "root.md"));
|
||||
assert!(entries.iter().any(|entry| entry.filename == "project.md"));
|
||||
|
||||
let folders = list_vault_folders(root).unwrap();
|
||||
let folders = list_vault_folders(root).await.unwrap();
|
||||
assert!(folders.iter().any(|folder| folder.name == "Projects"));
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::migrate_is_a_to_type(&vault_path)?;
|
||||
vault::repair_config_files(&vault_path)?;
|
||||
git::ensure_gitignore(&vault_path)?;
|
||||
git::ensure_gitignore(std::path::Path::new(vault_path.as_ref()))?;
|
||||
Ok("Vault repaired".to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -81,4 +81,14 @@ mod tests {
|
||||
|
||||
assert!(list_views(vault_path).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_view_command_treats_missing_backing_file_as_deleted() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_string_lossy().to_string();
|
||||
|
||||
delete_view_cmd(vault_path.clone(), "stale-view.yml".to_string()).unwrap();
|
||||
|
||||
assert!(list_views(vault_path).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
178
src-tauri/src/frontmatter/keys.rs
Normal file
178
src-tauri/src/frontmatter/keys.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct FrontmatterKeyRule {
|
||||
read_key: &'static str,
|
||||
write_key: &'static str,
|
||||
aliases: &'static [&'static str],
|
||||
canonicalize_on_write: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct FrontmatterKey<'a>(&'a str);
|
||||
|
||||
impl<'a> FrontmatterKey<'a> {
|
||||
pub(crate) fn new(key: &'a str) -> Self {
|
||||
Self(key)
|
||||
}
|
||||
|
||||
pub(crate) fn normalized(self) -> String {
|
||||
self.0.trim().to_ascii_lowercase().replace(' ', "_")
|
||||
}
|
||||
|
||||
pub(crate) fn is_reserved(self) -> bool {
|
||||
self.normalized().starts_with('_') || is_known_frontmatter_key(self)
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_FRONTMATTER_KEYS: &[FrontmatterKeyRule] = &[
|
||||
FrontmatterKeyRule {
|
||||
read_key: "title",
|
||||
write_key: "title",
|
||||
aliases: &["title"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "type",
|
||||
write_key: "type",
|
||||
aliases: &["type", "is_a", "Is A"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "aliases",
|
||||
write_key: "aliases",
|
||||
aliases: &["aliases"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_archived",
|
||||
write_key: "_archived",
|
||||
aliases: &["_archived", "Archived", "archived"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "Status",
|
||||
write_key: "Status",
|
||||
aliases: &["Status", "status"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_icon",
|
||||
write_key: "_icon",
|
||||
aliases: &["_icon", "icon"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "color",
|
||||
write_key: "color",
|
||||
aliases: &["color"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_order",
|
||||
write_key: "_order",
|
||||
aliases: &["_order", "order"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_sidebar_label",
|
||||
write_key: "_sidebar_label",
|
||||
aliases: &["_sidebar_label", "sidebar_label", "sidebar label"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "template",
|
||||
write_key: "template",
|
||||
aliases: &["template"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_sort",
|
||||
write_key: "_sort",
|
||||
aliases: &["_sort", "sort"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "view",
|
||||
write_key: "view",
|
||||
aliases: &["view"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_width",
|
||||
write_key: "_width",
|
||||
aliases: &["_width", "width"],
|
||||
canonicalize_on_write: true,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "visible",
|
||||
write_key: "visible",
|
||||
aliases: &["visible"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_organized",
|
||||
write_key: "_organized",
|
||||
aliases: &["_organized"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_favorite",
|
||||
write_key: "_favorite",
|
||||
aliases: &["_favorite"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_favorite_index",
|
||||
write_key: "_favorite_index",
|
||||
aliases: &["_favorite_index"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
FrontmatterKeyRule {
|
||||
read_key: "_list_properties_display",
|
||||
write_key: "_list_properties_display",
|
||||
aliases: &["_list_properties_display"],
|
||||
canonicalize_on_write: false,
|
||||
},
|
||||
];
|
||||
|
||||
impl FrontmatterKeyRule {
|
||||
pub(crate) fn read_key(self) -> &'static str {
|
||||
self.read_key
|
||||
}
|
||||
|
||||
pub(crate) fn write_key(self) -> &'static str {
|
||||
self.write_key
|
||||
}
|
||||
|
||||
pub(crate) fn canonicalizes_on_write(self) -> bool {
|
||||
self.canonicalize_on_write
|
||||
}
|
||||
|
||||
fn matches(self, key: FrontmatterKey<'_>) -> bool {
|
||||
let normalized = key.normalized();
|
||||
self.aliases
|
||||
.iter()
|
||||
.any(|alias| FrontmatterKey::new(alias).normalized() == normalized)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn frontmatter_key_rule(key: FrontmatterKey<'_>) -> Option<FrontmatterKeyRule> {
|
||||
KNOWN_FRONTMATTER_KEYS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|rule| rule.matches(key))
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_known_frontmatter_key(key: FrontmatterKey<'_>) -> Option<&'static str> {
|
||||
frontmatter_key_rule(key).map(FrontmatterKeyRule::read_key)
|
||||
}
|
||||
|
||||
pub(crate) fn frontmatter_keys_match(left: FrontmatterKey<'_>, right: FrontmatterKey<'_>) -> bool {
|
||||
match (frontmatter_key_rule(left), frontmatter_key_rule(right)) {
|
||||
(Some(left_rule), Some(right_rule)) => left_rule.read_key() == right_rule.read_key(),
|
||||
_ => left.normalized() == right.normalized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_known_frontmatter_key(key: FrontmatterKey<'_>) -> bool {
|
||||
frontmatter_key_rule(key).is_some()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub(crate) mod keys;
|
||||
mod ops;
|
||||
#[cfg(test)]
|
||||
mod ops_update_tests;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::keys::{frontmatter_key_rule, frontmatter_keys_match, FrontmatterKey};
|
||||
use super::yaml::{format_yaml_field, FrontmatterValue};
|
||||
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
@@ -7,31 +8,9 @@ fn is_value_continuation(line: FrontmatterLine<'_>) -> bool {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum SystemKey {
|
||||
Icon,
|
||||
Order,
|
||||
SidebarLabel,
|
||||
Sort,
|
||||
}
|
||||
|
||||
impl SystemKey {
|
||||
fn canonical(self) -> &'static str {
|
||||
match self {
|
||||
Self::Icon => "_icon",
|
||||
Self::Order => "_order",
|
||||
Self::SidebarLabel => "_sidebar_label",
|
||||
Self::Sort => "_sort",
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_aliases(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Icon => &["icon"],
|
||||
Self::Order => &["order"],
|
||||
Self::SidebarLabel => &["sidebar_label", "sidebar label"],
|
||||
Self::Sort => &["sort"],
|
||||
}
|
||||
}
|
||||
enum KeyMatchMode {
|
||||
Exact,
|
||||
Canonical,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -47,31 +26,50 @@ impl<'a> PropertyKey<'a> {
|
||||
fn as_str(self) -> &'a str {
|
||||
self.0
|
||||
}
|
||||
|
||||
fn matches(self, candidate: &str, mode: KeyMatchMode) -> bool {
|
||||
match mode {
|
||||
KeyMatchMode::Exact => candidate == self.as_str(),
|
||||
KeyMatchMode::Canonical => frontmatter_keys_match(
|
||||
FrontmatterKey::new(candidate),
|
||||
FrontmatterKey::new(self.as_str()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> FrontmatterLine<'a> {
|
||||
fn key(self) -> Option<&'a str> {
|
||||
let trimmed = self.0.trim_start();
|
||||
if let Some(raw) = trimmed.strip_prefix('"') {
|
||||
return quoted_yaml_key(raw, '"');
|
||||
}
|
||||
if let Some(raw) = trimmed.strip_prefix('\'') {
|
||||
return quoted_yaml_key(raw, '\'');
|
||||
}
|
||||
trimmed
|
||||
.split_once(':')
|
||||
.map(|(key, _)| key.trim())
|
||||
.filter(|key| !key.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
fn quoted_yaml_key(raw: &str, quote: char) -> Option<&str> {
|
||||
let (key, rest) = raw.split_once(quote)?;
|
||||
rest.trim_start().starts_with(':').then_some(key)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct FieldUpdate<'a> {
|
||||
key: PropertyKey<'a>,
|
||||
value: Option<&'a FrontmatterValue>,
|
||||
match_mode: KeyMatchMode,
|
||||
}
|
||||
|
||||
impl<'a> FieldUpdate<'a> {
|
||||
fn matches_line(self, line: FrontmatterLine<'_>) -> bool {
|
||||
let trimmed = line.0.trim_start();
|
||||
|
||||
if trimmed.starts_with(self.key.as_str())
|
||||
&& trimmed[self.key.as_str().len()..].starts_with(':')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let double_quoted = format!("\"{}\":", self.key.as_str());
|
||||
if trimmed.starts_with(&double_quoted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let single_quoted = format!("'{}\':", self.key.as_str());
|
||||
trimmed.starts_with(&single_quoted)
|
||||
line.key()
|
||||
.is_some_and(|candidate| self.key.matches(candidate, self.match_mode))
|
||||
}
|
||||
|
||||
fn prepend_to(self, content: DocumentText<'_>) -> String {
|
||||
@@ -133,22 +131,6 @@ impl<'a> FieldUpdate<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_system_key(key: PropertyKey<'_>) -> Option<SystemKey> {
|
||||
match key
|
||||
.as_str()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.replace(' ', "_")
|
||||
.as_str()
|
||||
{
|
||||
"_icon" | "icon" => Some(SystemKey::Icon),
|
||||
"_order" | "order" => Some(SystemKey::Order),
|
||||
"_sidebar_label" | "sidebar_label" | "sidebar label" => Some(SystemKey::SidebarLabel),
|
||||
"_sort" | "sort" => Some(SystemKey::Sort),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal function to update frontmatter content
|
||||
pub fn update_frontmatter_content(
|
||||
content: &str,
|
||||
@@ -158,23 +140,25 @@ pub fn update_frontmatter_content(
|
||||
let update = FieldUpdate {
|
||||
key: PropertyKey(key),
|
||||
value: value.as_ref(),
|
||||
match_mode: KeyMatchMode::Exact,
|
||||
};
|
||||
let Some(system_key) = canonical_system_key(update.key) else {
|
||||
let Some(rule) = frontmatter_key_rule(FrontmatterKey::new(update.key.as_str()))
|
||||
.filter(|rule| rule.canonicalizes_on_write())
|
||||
else {
|
||||
return update.apply_to_content(DocumentText(content));
|
||||
};
|
||||
|
||||
let mut updated = content.to_string();
|
||||
for alias in system_key.legacy_aliases() {
|
||||
updated = FieldUpdate {
|
||||
key: PropertyKey(alias),
|
||||
value: None,
|
||||
}
|
||||
.apply_to_content(DocumentText(&updated))?;
|
||||
let updated = FieldUpdate {
|
||||
key: PropertyKey(rule.write_key()),
|
||||
value: None,
|
||||
match_mode: KeyMatchMode::Canonical,
|
||||
}
|
||||
.apply_to_content(DocumentText(content))?;
|
||||
|
||||
FieldUpdate {
|
||||
key: PropertyKey(system_key.canonical()),
|
||||
key: PropertyKey(rule.write_key()),
|
||||
value: update.value,
|
||||
match_mode: KeyMatchMode::Exact,
|
||||
}
|
||||
.apply_to_content(DocumentText(&updated))
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ fn test_update_frontmatter_replaces_or_adds_scalar_fields() {
|
||||
content: "---\n\"Is A\": Note\n---\n# Test\n",
|
||||
key: "Is A",
|
||||
value: Some(FrontmatterValue::String("Project".to_string())),
|
||||
expected_present: &["\"Is A\": Project"],
|
||||
expected_absent: &["\"Is A\": Note"],
|
||||
expected_present: &["type: Project"],
|
||||
expected_absent: &["\"Is A\": Note", "\"Is A\": Project"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nStatus: Draft\n---\n# Test\n",
|
||||
@@ -163,6 +163,13 @@ fn test_update_frontmatter_handles_missing_or_malformed_frontmatter() {
|
||||
#[test]
|
||||
fn test_update_frontmatter_canonicalizes_system_metadata_keys() {
|
||||
let cases = [
|
||||
UpdateCase {
|
||||
content: "---\narchived: false\n---\n# Test\n",
|
||||
key: "_archived",
|
||||
value: Some(FrontmatterValue::Bool(true)),
|
||||
expected_present: &["_archived: true"],
|
||||
expected_absent: &["archived: false"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nicon: rocket\n---\n# Test\n",
|
||||
key: "icon",
|
||||
@@ -190,3 +197,41 @@ fn test_update_frontmatter_canonicalizes_system_metadata_keys() {
|
||||
assert_updated_content(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_canonicalizes_type_key_case() {
|
||||
let cases = [
|
||||
UpdateCase {
|
||||
content: "---\nType: Note\n---\n# Test\n",
|
||||
key: "type",
|
||||
value: Some(FrontmatterValue::String("Project".to_string())),
|
||||
expected_present: &["type: Project"],
|
||||
expected_absent: &["Type: Note"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\n\"Is A\": Note\nis_a: Topic\n---\n# Test\n",
|
||||
key: "type",
|
||||
value: Some(FrontmatterValue::String("Project".to_string())),
|
||||
expected_present: &["type: Project"],
|
||||
expected_absent: &["\"Is A\": Note", "is_a: Topic"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nTYPE: Note\n---\n# Test\n",
|
||||
key: "Type",
|
||||
value: Some(FrontmatterValue::String("Person".to_string())),
|
||||
expected_present: &["type: Person"],
|
||||
expected_absent: &["TYPE: Note"],
|
||||
},
|
||||
UpdateCase {
|
||||
content: "---\nType: Note\nstatus: Active\n---\n# Test\n",
|
||||
key: "type",
|
||||
value: None,
|
||||
expected_present: &["status: Active", "# Test"],
|
||||
expected_absent: &["Type: Note", "\ntype:"],
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_updated_content(case);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub(crate) fn build_command(
|
||||
) -> Result<std::process::Command, String> {
|
||||
let settings_path = write_settings(settings_dir, &request.vault_path, request.permission_mode)?;
|
||||
let mut command = crate::hidden_command(binary);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
||||
command
|
||||
.args(build_args(request.permission_mode))
|
||||
.arg("--prompt")
|
||||
@@ -122,6 +123,28 @@ mod tests {
|
||||
assert!(settings_dir.path().join("settings.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_extends_path_with_resolved_homebrew_bin() {
|
||||
let settings_dir = tempfile::tempdir().unwrap();
|
||||
let command = build_command(
|
||||
&PathBuf::from("/opt/homebrew/bin/gemini"),
|
||||
&request(),
|
||||
settings_dir.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let path_value = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == OsStr::new("PATH"))
|
||||
.and_then(|(_, value)| value)
|
||||
.expect("PATH should be set");
|
||||
let paths = std::env::split_paths(path_value).collect::<Vec<_>>();
|
||||
|
||||
assert!(
|
||||
paths.contains(&PathBuf::from("/opt/homebrew/bin")),
|
||||
"PATH should include the resolved Gemini binary directory, got {paths:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_settings_include_tolaria_mcp_and_exclude_shell() {
|
||||
let settings = build_settings("/tmp/vault", AiAgentPermissionMode::Safe).unwrap();
|
||||
|
||||
@@ -244,6 +244,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"-c".to_string(),
|
||||
"core.quotePath=false".to_string(),
|
||||
"clone".to_string(),
|
||||
"--quiet".to_string(),
|
||||
"https://example.com/repo.git".to_string(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::git_command;
|
||||
use crate::vault::path_identity::vault_relative_path_string;
|
||||
use std::path::Path;
|
||||
|
||||
use super::GitCommit;
|
||||
@@ -7,14 +8,7 @@ use super::GitCommit;
|
||||
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
let relative = file
|
||||
.strip_prefix(vault)
|
||||
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
||||
|
||||
let relative_str = relative
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
let relative_str = vault_relative_path_string(vault, file)?;
|
||||
|
||||
let output = git_command()
|
||||
.args([
|
||||
@@ -23,7 +17,7 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
|
||||
"-n",
|
||||
"20",
|
||||
"--",
|
||||
relative_str,
|
||||
&relative_str,
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -70,18 +64,11 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
|
||||
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
let relative = file
|
||||
.strip_prefix(vault)
|
||||
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
||||
|
||||
let relative_str = relative
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
let relative_str = vault_relative_path_string(vault, file)?;
|
||||
|
||||
// First try tracked file diff
|
||||
let output = git_command()
|
||||
.args(["diff", "--", relative_str])
|
||||
.args(["diff", "--", &relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff: {}", e))?;
|
||||
@@ -91,7 +78,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
// If no diff (maybe staged or untracked), try diff --cached
|
||||
if stdout.is_empty() {
|
||||
let cached = git_command()
|
||||
.args(["diff", "--cached", "--", relative_str])
|
||||
.args(["diff", "--cached", "--", &relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
|
||||
@@ -103,7 +90,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
|
||||
|
||||
// Try showing untracked file as all-new
|
||||
let status = git_command()
|
||||
.args(["status", "--porcelain", "--", relative_str])
|
||||
.args(["status", "--porcelain", "--", &relative_str])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git status: {}", e))?;
|
||||
@@ -134,14 +121,7 @@ pub fn get_file_diff_at_commit(
|
||||
) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let file = Path::new(file_path);
|
||||
|
||||
let relative = file
|
||||
.strip_prefix(vault)
|
||||
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
|
||||
|
||||
let relative_str = relative
|
||||
.to_str()
|
||||
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
|
||||
let relative_str = vault_relative_path_string(vault, file)?;
|
||||
|
||||
// Show diff between commit^ and commit for this file
|
||||
let output = git_command()
|
||||
@@ -150,7 +130,7 @@ pub fn get_file_diff_at_commit(
|
||||
&format!("{}^", commit_hash),
|
||||
commit_hash,
|
||||
"--",
|
||||
relative_str,
|
||||
&relative_str,
|
||||
])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -187,37 +167,53 @@ mod tests {
|
||||
use super::git_command;
|
||||
use super::*;
|
||||
use crate::git::tests::setup_git_repo;
|
||||
use std::fs;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
fn force_quoted_git_paths(vault: &Path) {
|
||||
git_command()
|
||||
.args(["config", "core.quotePath", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_and_commit_file(
|
||||
vault: &Path,
|
||||
relative_path: &str,
|
||||
content: &str,
|
||||
message: &str,
|
||||
) -> PathBuf {
|
||||
let file = vault.join(relative_path);
|
||||
fs::write(&file, content).unwrap();
|
||||
git_command()
|
||||
.args(["add", relative_path])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", message])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
file
|
||||
}
|
||||
|
||||
fn head_hash(vault: &Path) -> String {
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&log.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_history_with_commits() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("test.md");
|
||||
|
||||
fs::write(&file, "# Initial\n").unwrap();
|
||||
git_command()
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
fs::write(&file, "# Updated\n\nNew content.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Update test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let file = write_and_commit_file(vault, "test.md", "# Initial\n", "Initial commit");
|
||||
write_and_commit_file(vault, "test.md", "# Updated\n\nNew content.", "Update test");
|
||||
|
||||
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
|
||||
|
||||
@@ -245,19 +241,13 @@ mod tests {
|
||||
fn test_get_file_diff() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("diff-test.md");
|
||||
|
||||
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "diff-test.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Add diff-test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let file = write_and_commit_file(
|
||||
vault,
|
||||
"diff-test.md",
|
||||
"# Test\n\nOriginal content.",
|
||||
"Add diff-test",
|
||||
);
|
||||
|
||||
fs::write(&file, "# Test\n\nModified content.").unwrap();
|
||||
|
||||
@@ -272,39 +262,21 @@ mod tests {
|
||||
fn test_get_file_diff_at_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("diff-at-commit.md");
|
||||
|
||||
fs::write(&file, "# First\n\nOriginal content.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "First commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let file = write_and_commit_file(
|
||||
vault,
|
||||
"diff-at-commit.md",
|
||||
"# First\n\nOriginal content.",
|
||||
"First commit",
|
||||
);
|
||||
write_and_commit_file(
|
||||
vault,
|
||||
"diff-at-commit.md",
|
||||
"# First\n\nModified content.",
|
||||
"Second commit",
|
||||
);
|
||||
|
||||
fs::write(&file, "# First\n\nModified content.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "diff-at-commit.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Second commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Get hash of second commit
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
||||
let hash = head_hash(vault);
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
@@ -318,26 +290,15 @@ mod tests {
|
||||
fn test_get_file_diff_at_initial_commit() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let file = vault.join("initial.md");
|
||||
|
||||
fs::write(&file, "# Initial\n\nHello world.").unwrap();
|
||||
git_command()
|
||||
.args(["add", "initial.md"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
git_command()
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let file = write_and_commit_file(
|
||||
vault,
|
||||
"initial.md",
|
||||
"# Initial\n\nHello world.",
|
||||
"Initial commit",
|
||||
);
|
||||
|
||||
let log = git_command()
|
||||
.args(["log", "--format=%H", "-1"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
|
||||
let hash = head_hash(vault);
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
@@ -346,4 +307,35 @@ mod tests {
|
||||
assert!(diff.contains("+# Initial"));
|
||||
assert!(diff.contains("+Hello world."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_file_diff_at_commit_preserves_chinese_filename_and_content() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let relative_path = "中文笔记.md";
|
||||
let file = vault.join(relative_path);
|
||||
|
||||
force_quoted_git_paths(vault);
|
||||
write_and_commit_file(
|
||||
vault,
|
||||
relative_path,
|
||||
"# 初始\n\n第一行\n",
|
||||
"Add Chinese note",
|
||||
);
|
||||
write_and_commit_file(
|
||||
vault,
|
||||
relative_path,
|
||||
"# 初始\n\n第二行\n",
|
||||
"Update Chinese note",
|
||||
);
|
||||
let hash = head_hash(vault);
|
||||
|
||||
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
|
||||
.unwrap();
|
||||
|
||||
assert!(diff.contains("diff --git a/中文笔记.md b/中文笔记.md"));
|
||||
assert!(diff.contains("-第一行"));
|
||||
assert!(diff.contains("+第二行"));
|
||||
assert!(!diff.contains("\\344"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,14 +56,47 @@ const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never co
|
||||
*.swp\n\
|
||||
*.swo\n";
|
||||
|
||||
fn git_command() -> Command {
|
||||
crate::hidden_command("git")
|
||||
pub(crate) fn git_command() -> Command {
|
||||
let mut command = crate::hidden_command("git");
|
||||
sanitize_linux_appimage_git_env(&mut command);
|
||||
command.args(["-c", "core.quotePath=false"]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
const LINUX_APPIMAGE_GIT_ENV_REMOVALS: [&str; 3] =
|
||||
["LD_LIBRARY_PATH", "LD_PRELOAD", "GIT_EXEC_PATH"];
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn sanitize_linux_appimage_git_env(command: &mut Command) {
|
||||
sanitize_linux_appimage_git_env_for_launch(command, linux_appimage_env_present());
|
||||
}
|
||||
|
||||
#[cfg(not(all(desktop, target_os = "linux")))]
|
||||
fn sanitize_linux_appimage_git_env(_command: &mut Command) {}
|
||||
|
||||
#[cfg(any(test, all(desktop, target_os = "linux")))]
|
||||
fn sanitize_linux_appimage_git_env_for_launch(command: &mut Command, is_appimage: bool) {
|
||||
if !is_appimage {
|
||||
return;
|
||||
}
|
||||
|
||||
for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS {
|
||||
command.env_remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn linux_appimage_env_present() -> bool {
|
||||
["APPIMAGE", "APPDIR"]
|
||||
.into_iter()
|
||||
.any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// Ensure a `.gitignore` with sensible defaults exists in the vault directory.
|
||||
/// Creates the file if missing; leaves existing `.gitignore` files untouched.
|
||||
pub fn ensure_gitignore(path: &str) -> Result<(), String> {
|
||||
let gitignore_path = Path::new(path).join(".gitignore");
|
||||
pub fn ensure_gitignore(path: impl AsRef<Path>) -> Result<(), String> {
|
||||
let gitignore_path = path.as_ref().join(".gitignore");
|
||||
if !gitignore_path.exists() {
|
||||
std::fs::write(&gitignore_path, DEFAULT_GITIGNORE)
|
||||
.map_err(|e| format!("Failed to write .gitignore: {}", e))?;
|
||||
@@ -72,15 +105,15 @@ pub fn ensure_gitignore(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Initialize a new git repository, stage all files, and create an initial commit.
|
||||
pub fn init_repo(path: &str) -> Result<(), String> {
|
||||
let dir = Path::new(path);
|
||||
pub fn init_repo(path: impl AsRef<Path>) -> Result<(), String> {
|
||||
let dir = path.as_ref();
|
||||
|
||||
run_git(dir, &["init"])?;
|
||||
ensure_author_config(dir)?;
|
||||
|
||||
// Write .gitignore before the first commit so machine-specific and
|
||||
// macOS metadata files are never tracked and don't cause conflicts.
|
||||
ensure_gitignore(path)?;
|
||||
ensure_gitignore(dir)?;
|
||||
|
||||
run_git(dir, &["add", "."])?;
|
||||
commit_initial_vault_setup(dir)?;
|
||||
@@ -176,6 +209,7 @@ fn parse_github_repo_path(url: &str) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -259,6 +293,18 @@ mod tests {
|
||||
(bare_dir, clone_a_dir, clone_b_dir)
|
||||
}
|
||||
|
||||
fn command_envs(command: &Command) -> HashMap<String, Option<String>> {
|
||||
command
|
||||
.get_envs()
|
||||
.map(|(key, value)| {
|
||||
(
|
||||
key.to_string_lossy().to_string(),
|
||||
value.map(|entry| entry.to_string_lossy().to_string()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ensure_gitignore_creates_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -282,6 +328,32 @@ mod tests {
|
||||
assert_eq!(content, "my-rule\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_linux_appimage_git_commands_remove_appimage_loader_env() {
|
||||
let mut command = crate::hidden_command("git");
|
||||
|
||||
sanitize_linux_appimage_git_env_for_launch(&mut command, true);
|
||||
|
||||
let envs = command_envs(&command);
|
||||
|
||||
for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS {
|
||||
assert_eq!(envs.get(key), Some(&None));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_appimage_git_commands_keep_parent_env_unmodified() {
|
||||
let mut command = crate::hidden_command("git");
|
||||
|
||||
sanitize_linux_appimage_git_env_for_launch(&mut command, false);
|
||||
|
||||
let envs = command_envs(&command);
|
||||
|
||||
for key in LINUX_APPIMAGE_GIT_ENV_REMOVALS {
|
||||
assert!(!envs.contains_key(key));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_repo_creates_git_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -300,6 +300,14 @@ mod tests {
|
||||
git_commit(vp, "initial").unwrap();
|
||||
}
|
||||
|
||||
fn force_quoted_git_paths(vault: &Path) {
|
||||
git_command()
|
||||
.args(["config", "core.quotePath", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files() {
|
||||
let dir = setup_git_repo();
|
||||
@@ -380,6 +388,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_modified_files_preserves_chinese_markdown_path() {
|
||||
let dir = setup_git_repo();
|
||||
let vault = dir.path();
|
||||
let vp = vault.to_str().unwrap();
|
||||
let relative_path = "中文笔记.md";
|
||||
|
||||
force_quoted_git_paths(vault);
|
||||
write_and_commit_markdown(vault, vp, relative_path, "# 初始\n");
|
||||
fs::write(vault.join(relative_path), "# 初始\n\n更新\n").unwrap();
|
||||
|
||||
let modified = get_modified_files(vp).unwrap();
|
||||
let file = modified
|
||||
.iter()
|
||||
.find(|file| file.relative_path == relative_path)
|
||||
.expect("Chinese markdown path should be reported as modified");
|
||||
|
||||
assert_eq!(file.status, "modified");
|
||||
assert!(file.path.ends_with(relative_path));
|
||||
assert_eq!(file.added_lines, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commit_flow_modified_files_then_commit_clears() {
|
||||
let dir = setup_git_repo();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod ai_agents;
|
||||
pub mod ai_models;
|
||||
pub mod app_updater;
|
||||
pub mod claude_cli;
|
||||
mod cli_agent_runtime;
|
||||
@@ -9,6 +10,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 +35,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 +66,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 {
|
||||
@@ -543,6 +414,10 @@ macro_rules! app_invoke_handler {
|
||||
commands::restore_vault_ai_guidance,
|
||||
commands::stream_claude_chat,
|
||||
commands::stream_ai_agent,
|
||||
commands::stream_ai_model,
|
||||
commands::save_ai_model_provider_api_key,
|
||||
commands::delete_ai_model_provider_api_key,
|
||||
commands::test_ai_model_provider,
|
||||
commands::reload_vault,
|
||||
commands::reload_vault_entry,
|
||||
commands::sync_vault_asset_scope_for_window,
|
||||
@@ -579,7 +454,9 @@ macro_rules! app_invoke_handler {
|
||||
commands::check_mcp_status,
|
||||
commands::get_mcp_config_snippet,
|
||||
commands::copy_text_to_clipboard,
|
||||
commands::read_text_from_clipboard,
|
||||
commands::sync_mcp_bridge_vault,
|
||||
commands::get_process_memory_snapshot,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
@@ -610,7 +487,9 @@ fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
apply_linux_appimage_startup_env_overrides();
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
linux_appimage::apply_startup_env_overrides();
|
||||
|
||||
let builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -632,9 +511,6 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::linux_appimage_startup_env_overrides_with;
|
||||
use super::linux_appimage_wayland_client_preload_path_with;
|
||||
use super::StartupEnvOverride;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
@@ -657,98 +533,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() {
|
||||
|
||||
474
src-tauri/src/linux_appimage.rs
Normal file
474
src-tauri/src/linux_appimage.rs
Normal file
@@ -0,0 +1,474 @@
|
||||
#[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 COLRV1_EMOJI_FONT_FILE: &str = "Noto-COLRv1.ttf";
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
const TOLARIA_COLRV1_FONTCONFIG_FILE: &str = "tolaria-appimage-no-colrv1-emoji.conf";
|
||||
|
||||
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 has_non_empty_env<F>(get_var: &mut F, key: &str) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
get_var(key).is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn has_explicit_fontconfig_env<F>(get_var: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
["FONTCONFIG_FILE", "FONTCONFIG_PATH"]
|
||||
.into_iter()
|
||||
.any(|key| has_non_empty_env(get_var, key))
|
||||
}
|
||||
|
||||
fn can_apply_colrv1_font_guard<F>(get_var: &mut F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
if !is_linux_appimage_launch(&mut *get_var) {
|
||||
return false;
|
||||
}
|
||||
|
||||
!has_explicit_fontconfig_env(get_var)
|
||||
}
|
||||
|
||||
fn is_wayland_session<F>(mut get_var: F) -> bool
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
has_non_empty_env(&mut get_var, "WAYLAND_DISPLAY")
|
||||
|| 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 has_non_empty_env(&mut get_var, "LD_PRELOAD")
|
||||
|| 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 colrv1_emoji_font_path_with<F, M>(mut get_var: F, mut match_emoji_font: M) -> Option<String>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
M: FnMut() -> Option<String>,
|
||||
{
|
||||
if !can_apply_colrv1_font_guard(&mut get_var) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let font_path = match_emoji_font()?;
|
||||
|
||||
std::path::Path::new(font_path.trim())
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(COLRV1_EMOJI_FONT_FILE))
|
||||
.then(|| font_path.trim().to_string())
|
||||
}
|
||||
|
||||
fn escape_xml_text(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
fn colrv1_emoji_fontconfig_contents(rejected_font_path: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<fontconfig>
|
||||
<include ignore_missing="yes">/etc/fonts/fonts.conf</include>
|
||||
<selectfont>
|
||||
<rejectfont>
|
||||
<pattern>
|
||||
<patelt name="file">
|
||||
<string>{}</string>
|
||||
</patelt>
|
||||
</pattern>
|
||||
</rejectfont>
|
||||
</selectfont>
|
||||
</fontconfig>
|
||||
"#,
|
||||
escape_xml_text(rejected_font_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| !has_non_empty_env(&mut get_var, env_override.key))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
pub(crate) fn apply_startup_env_overrides() {
|
||||
apply_wayland_client_preload();
|
||||
apply_colrv1_emoji_font_guard();
|
||||
|
||||
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_colrv1_emoji_font_guard() {
|
||||
let Some(font_path) =
|
||||
colrv1_emoji_font_path_with(|key| std::env::var(key).ok(), match_emoji_font_path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(config_path) = colrv1_fontconfig_file_path() else {
|
||||
eprintln!("Tolaria AppImage COLRv1 font guard skipped: failed to resolve cache directory");
|
||||
return;
|
||||
};
|
||||
let Some(parent) = config_path.parent() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(error) = std::fs::create_dir_all(parent) {
|
||||
eprintln!("Tolaria AppImage COLRv1 font guard skipped: failed to prepare cache ({error})");
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(error) = std::fs::write(&config_path, colrv1_emoji_fontconfig_contents(&font_path)) {
|
||||
eprintln!("Tolaria AppImage COLRv1 font guard skipped: failed to write config ({error})");
|
||||
return;
|
||||
}
|
||||
|
||||
std::env::set_var("FONTCONFIG_FILE", config_path.as_os_str());
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn match_emoji_font_path() -> Option<String> {
|
||||
let output = std::process::Command::new("fc-match")
|
||||
.args(["-f", "%{file}\n", "emoji"])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn colrv1_fontconfig_file_path() -> Option<std::path::PathBuf> {
|
||||
let cache_dir =
|
||||
dirs::cache_dir().or_else(|| dirs::home_dir().map(|home| home.join(".cache")))?;
|
||||
|
||||
Some(
|
||||
cache_dir
|
||||
.join("tolaria")
|
||||
.join(TOLARIA_COLRV1_FONTCONFIG_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
#[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::{
|
||||
colrv1_emoji_font_path_with, colrv1_emoji_fontconfig_contents, 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 colrv1_font_guard_targets_reported_appimage_emoji_font() {
|
||||
let font_path = colrv1_emoji_font_path_with(
|
||||
|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|| Some("/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
font_path.as_deref(),
|
||||
Some("/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colrv1_font_guard_preserves_explicit_fontconfig_settings() {
|
||||
let font_path = colrv1_emoji_font_path_with(
|
||||
|key| match key {
|
||||
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
|
||||
"FONTCONFIG_FILE" => Some("/tmp/custom-fontconfig.conf".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|| Some("/usr/share/fonts/google-noto-color-emoji-fonts/Noto-COLRv1.ttf".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(font_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colrv1_font_guard_ignores_other_emoji_fonts() {
|
||||
let font_path = colrv1_emoji_font_path_with(
|
||||
|key| match key {
|
||||
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
|| Some("/usr/share/fonts/noto/NotoColorEmoji.ttf".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(font_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colrv1_fontconfig_includes_system_fonts_and_rejects_matched_file() {
|
||||
let contents = colrv1_emoji_fontconfig_contents("/tmp/fonts/A&B/Noto-COLRv1.ttf");
|
||||
|
||||
assert!(
|
||||
contents.contains("<include ignore_missing=\"yes\">/etc/fonts/fonts.conf</include>")
|
||||
);
|
||||
assert!(contents.contains("<patelt name=\"file\">"));
|
||||
assert!(contents.contains("<string>/tmp/fonts/A&B/Noto-COLRv1.ttf</string>"));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -284,6 +284,10 @@ fn linux_package_mcp_server_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
root.join("Tolaria").join("mcp-server"),
|
||||
root.join("Tolaria").join("resources").join("mcp-server"),
|
||||
root.join("lib").join("tolaria").join("mcp-server"),
|
||||
root.join("lib")
|
||||
.join("tolaria")
|
||||
.join("resources")
|
||||
.join("mcp-server"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -759,12 +763,26 @@ mod tests {
|
||||
PathBuf::from("/usr/local/Tolaria/mcp-server"),
|
||||
PathBuf::from("/usr/local/Tolaria/resources/mcp-server"),
|
||||
PathBuf::from("/usr/local/lib/tolaria/mcp-server"),
|
||||
PathBuf::from("/usr/local/lib/tolaria/resources/mcp-server"),
|
||||
PathBuf::from("/usr/lib/tolaria/mcp-server"),
|
||||
PathBuf::from("/usr/lib/tolaria/resources/mcp-server"),
|
||||
];
|
||||
|
||||
assert!(expected.iter().all(|path| candidates.contains(path)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_dir_candidates_include_linux_appimage_resource_root() {
|
||||
let dev_path = Path::new("/repo/mcp-server");
|
||||
let exe_path = Path::new("/tmp/.mount_tolaria/usr/bin/tolaria");
|
||||
let appdir = Path::new("/tmp/.mount_tolaria");
|
||||
let candidates = mcp_server_dir_candidates(dev_path, exe_path, Some(appdir));
|
||||
|
||||
assert!(candidates.contains(&PathBuf::from(
|
||||
"/tmp/.mount_tolaria/usr/lib/tolaria/resources/mcp-server"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_creates_new_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ pub(crate) fn build_command(
|
||||
request: &AgentStreamRequest,
|
||||
) -> Result<std::process::Command, String> {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
||||
command
|
||||
.args(build_args())
|
||||
.arg(build_prompt(request))
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) fn build_command(
|
||||
write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?;
|
||||
|
||||
let mut command = crate::hidden_command(binary);
|
||||
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
||||
command
|
||||
.args(build_args())
|
||||
.arg(build_prompt(request))
|
||||
|
||||
@@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::ai_models::{normalize_ai_model_providers, AiModelProvider};
|
||||
|
||||
const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
||||
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
||||
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
|
||||
@@ -78,6 +80,8 @@ pub struct Settings {
|
||||
pub note_width_mode: Option<String>,
|
||||
pub initial_h1_auto_rename_enabled: Option<bool>,
|
||||
pub default_ai_agent: Option<String>,
|
||||
pub default_ai_target: Option<String>,
|
||||
pub ai_model_providers: Option<Vec<AiModelProvider>>,
|
||||
pub hide_gitignored_files: Option<bool>,
|
||||
pub all_notes_show_pdfs: Option<bool>,
|
||||
pub all_notes_show_images: Option<bool>,
|
||||
@@ -179,6 +183,8 @@ fn normalize_settings(settings: Settings) -> Settings {
|
||||
note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()),
|
||||
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
|
||||
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
|
||||
default_ai_target: normalize_optional_string(settings.default_ai_target),
|
||||
ai_model_providers: normalize_ai_model_providers(settings.ai_model_providers),
|
||||
hide_gitignored_files: settings.hide_gitignored_files,
|
||||
all_notes_show_pdfs: settings.all_notes_show_pdfs,
|
||||
all_notes_show_images: settings.all_notes_show_images,
|
||||
@@ -326,6 +332,8 @@ mod tests {
|
||||
note_width_mode: Some("wide".to_string()),
|
||||
initial_h1_auto_rename_enabled: Some(false),
|
||||
default_ai_agent: Some("codex".to_string()),
|
||||
default_ai_target: Some("agent:codex".to_string()),
|
||||
ai_model_providers: None,
|
||||
hide_gitignored_files: Some(false),
|
||||
all_notes_show_pdfs: Some(true),
|
||||
all_notes_show_images: Some(true),
|
||||
|
||||
@@ -9,6 +9,10 @@ use uuid::Uuid;
|
||||
use crate::git::{get_all_file_dates, GitDates};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::path_identity::{
|
||||
normalize_path_for_identity, push_unique_relative_path, relative_path_key,
|
||||
vault_relative_path_string,
|
||||
};
|
||||
use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry};
|
||||
|
||||
// --- Vault Cache ---
|
||||
@@ -83,7 +87,7 @@ fn default_cache_version() -> u32 {
|
||||
/// Compute a deterministic hex hash of the vault path for use as cache filename.
|
||||
fn vault_path_hash(vault: &Path) -> String {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
vault.to_string_lossy().as_ref().hash(&mut hasher);
|
||||
normalize_path_for_identity(&vault.to_string_lossy()).hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
}
|
||||
|
||||
@@ -125,7 +129,7 @@ fn git_head_hash(vault: &Path) -> Option<String> {
|
||||
|
||||
/// Run a git command in the given directory and return stdout if successful.
|
||||
fn run_git(vault: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = crate::hidden_command("git")
|
||||
let output = crate::git::git_command()
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -148,28 +152,22 @@ fn parse_porcelain_line(line: &str) -> Option<(&str, String)> {
|
||||
/// Includes all non-hidden files (not just .md) so the cache picks up
|
||||
/// view files (.yml), binary assets, etc.
|
||||
fn collect_paths_from_diff(stdout: &str) -> Vec<String> {
|
||||
stdout
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty() && !has_hidden_segment(line))
|
||||
.map(|line| line.to_string())
|
||||
.collect()
|
||||
let mut paths = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
push_unique_relative_path(&mut paths, line);
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// Extract file paths from git status --porcelain output.
|
||||
/// Includes all non-hidden files so incremental cache updates cover
|
||||
/// every file type the vault scanner recognises.
|
||||
fn collect_paths_from_porcelain(stdout: &str) -> Vec<String> {
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(parse_porcelain_line)
|
||||
.filter(|(_, path)| !has_hidden_segment(path))
|
||||
.map(|(_, path)| path)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return true if any path segment starts with `.` (hidden file/directory).
|
||||
fn has_hidden_segment(path: &str) -> bool {
|
||||
path.split('/').any(|seg| seg.starts_with('.'))
|
||||
let mut paths = Vec::new();
|
||||
for (_, path) in stdout.lines().filter_map(parse_porcelain_line) {
|
||||
push_unique_relative_path(&mut paths, path);
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String> {
|
||||
@@ -182,9 +180,7 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
|
||||
let uncommitted = git_uncommitted_files(vault);
|
||||
|
||||
for path in uncommitted.into_iter() {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
push_unique_relative_path(&mut files, path);
|
||||
}
|
||||
|
||||
files
|
||||
@@ -201,17 +197,16 @@ fn git_uncommitted_files(vault: &Path) -> Vec<String> {
|
||||
// files inside — ls-files resolves them so the cache picks up all new files.
|
||||
let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"])
|
||||
.map(|s| {
|
||||
s.lines()
|
||||
.filter(|l| !l.is_empty() && !has_hidden_segment(l))
|
||||
.map(|l| l.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
let mut paths = Vec::new();
|
||||
for line in s.lines() {
|
||||
push_unique_relative_path(&mut paths, line);
|
||||
}
|
||||
paths
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
for path in untracked {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
push_unique_relative_path(&mut files, path);
|
||||
}
|
||||
|
||||
files
|
||||
@@ -447,13 +442,12 @@ fn write_cache(
|
||||
|
||||
/// Normalize an absolute path to a relative path for comparison with git output.
|
||||
fn to_relative_path(abs_path: &str, vault: &Path) -> String {
|
||||
let vault_str = vault.to_string_lossy();
|
||||
let with_slash = format!("{}/", vault_str);
|
||||
abs_path
|
||||
.strip_prefix(&with_slash)
|
||||
.or_else(|| abs_path.strip_prefix(vault_str.as_ref()))
|
||||
.unwrap_or(abs_path)
|
||||
.to_string()
|
||||
vault_relative_path_string(vault, Path::new(abs_path))
|
||||
.unwrap_or_else(|_| normalize_path_for_identity(abs_path))
|
||||
}
|
||||
|
||||
fn to_relative_path_key(abs_path: &str, vault: &Path) -> String {
|
||||
relative_path_key(&to_relative_path(abs_path, vault))
|
||||
}
|
||||
|
||||
/// Parse files from a list of relative paths, skipping any that don't exist.
|
||||
@@ -535,7 +529,7 @@ fn prune_stale_entries(vault: &Path, entries: &mut Vec<VaultEntry>) -> bool {
|
||||
// Deduplicate by case-folded relative path
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
entries.retain(|e| {
|
||||
let rel = to_relative_path(&e.path, vault).to_lowercase();
|
||||
let rel = to_relative_path_key(&e.path, vault);
|
||||
seen.insert(rel)
|
||||
});
|
||||
entries.len() != before
|
||||
@@ -587,8 +581,9 @@ fn update_same_commit(
|
||||
let changed = git_uncommitted_files(vault);
|
||||
let mut entries = cache.entries;
|
||||
if !changed.is_empty() {
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault)));
|
||||
let changed_set: std::collections::HashSet<String> =
|
||||
changed.iter().map(|path| relative_path_key(path)).collect();
|
||||
entries.retain(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault)));
|
||||
entries.extend(parse_files_at(vault, &changed, git_dates));
|
||||
}
|
||||
// Always finalize: prune_stale_entries inside finalize_and_cache removes
|
||||
@@ -605,12 +600,15 @@ fn update_different_commit(
|
||||
) -> Vec<VaultEntry> {
|
||||
let LoadedCache { cache, fingerprint } = loaded_cache;
|
||||
let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash);
|
||||
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
|
||||
let changed_set: std::collections::HashSet<String> = changed_files
|
||||
.iter()
|
||||
.map(|path| relative_path_key(path))
|
||||
.collect();
|
||||
|
||||
let mut entries: Vec<VaultEntry> = cache
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.filter(|e| !changed_set.contains(&to_relative_path_key(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed_files, git_dates));
|
||||
|
||||
@@ -618,9 +616,10 @@ fn update_different_commit(
|
||||
}
|
||||
|
||||
fn cache_requires_full_rescan(cache: &VaultCache, vault_path: &Path) -> bool {
|
||||
let current_vault_str = vault_path.to_string_lossy();
|
||||
let current_vault_str = normalize_path_for_identity(&vault_path.to_string_lossy());
|
||||
cache.version != CACHE_VERSION
|
||||
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref())
|
||||
|| (!cache.vault_path.is_empty()
|
||||
&& normalize_path_for_identity(&cache.vault_path) != current_vault_str)
|
||||
}
|
||||
|
||||
fn scan_and_cache_full(
|
||||
@@ -768,6 +767,14 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn force_quoted_git_paths(vault: &Path) {
|
||||
crate::hidden_command("git")
|
||||
.args(["config", "core.quotePath", "true"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_path_is_outside_vault() {
|
||||
let _lock = ENV_LOCK.lock().unwrap();
|
||||
@@ -800,6 +807,17 @@ mod tests {
|
||||
assert_eq!(hash1, hash2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_relative_path_normalizes_aliases_and_separators() {
|
||||
assert_eq!(
|
||||
to_relative_path(
|
||||
"/tmp/tolaria-vault/projects\\active.md",
|
||||
Path::new("/private/tmp/tolaria-vault")
|
||||
),
|
||||
"projects/active.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_vaults_get_different_hashes() {
|
||||
let hash1 = vault_path_hash(Path::new("/Users/test/Vault1"));
|
||||
@@ -1007,6 +1025,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_uncommitted_files_preserves_chinese_markdown_path() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
let relative_path = "中文笔记.md";
|
||||
|
||||
force_quoted_git_paths(vault);
|
||||
create_test_file(vault, relative_path, "# 初始\n");
|
||||
git_add_commit(vault, "init");
|
||||
create_test_file(vault, relative_path, "# 初始\n\n更新\n");
|
||||
|
||||
let changed = git_uncommitted_files(vault);
|
||||
|
||||
assert_eq!(changed, vec![relative_path.to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_new_file_still_added() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
|
||||
@@ -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,32 +238,57 @@ 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;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::String(s) if contains_wikilink(s) => {
|
||||
relationships.insert(key.clone(), vec![s.clone()]);
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
let wikilinks: Vec<String> = arr
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.filter(|s| contains_wikilink(s))
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
if !wikilinks.is_empty() {
|
||||
relationships.insert(key.clone(), wikilinks);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
let wikilinks = relationship_wikilinks(value);
|
||||
if !wikilinks.is_empty() {
|
||||
relationships.insert(key.clone(), wikilinks);
|
||||
}
|
||||
}
|
||||
|
||||
relationships
|
||||
}
|
||||
|
||||
fn relationship_wikilinks(value: &serde_json::Value) -> Vec<String> {
|
||||
let mut wikilinks = Vec::new();
|
||||
collect_relationship_wikilinks(value, 0, &mut wikilinks);
|
||||
wikilinks
|
||||
}
|
||||
|
||||
fn collect_relationship_wikilinks(
|
||||
value: &serde_json::Value,
|
||||
depth: usize,
|
||||
wikilinks: &mut Vec<String>,
|
||||
) {
|
||||
match value {
|
||||
serde_json::Value::String(s) if contains_wikilink(s) => wikilinks.push(s.clone()),
|
||||
serde_json::Value::Array(arr) => {
|
||||
if let Some(link) = nested_flow_wikilink(arr, depth) {
|
||||
wikilinks.push(link);
|
||||
return;
|
||||
}
|
||||
for item in arr {
|
||||
collect_relationship_wikilinks(item, depth + 1, wikilinks);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn nested_flow_wikilink(arr: &[serde_json::Value], depth: usize) -> Option<String> {
|
||||
if depth == 0 {
|
||||
return None;
|
||||
}
|
||||
match arr {
|
||||
[serde_json::Value::String(target)] if !contains_wikilink(target) => {
|
||||
Some(format!("[[{target}]]"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract custom scalar properties from raw YAML frontmatter.
|
||||
pub(crate) fn extract_properties(
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
@@ -306,11 +296,14 @@ 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;
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::Null => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
serde_json::Value::String(s) if !contains_wikilink(s) => {
|
||||
properties.insert(key.clone(), value.clone());
|
||||
}
|
||||
@@ -419,7 +412,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 +512,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),
|
||||
)
|
||||
|
||||
@@ -114,6 +114,40 @@ fn test_single_element_array_properties_unwrap_to_scalars() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blank_scalar_properties_are_preserved() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"book.md",
|
||||
"---\ntype: Book\nstart date:\nrating: \n---\n# Book\n",
|
||||
);
|
||||
|
||||
assert!(entry
|
||||
.properties
|
||||
.get("start date")
|
||||
.is_some_and(|value| value.is_null()));
|
||||
assert!(entry
|
||||
.properties
|
||||
.get("rating")
|
||||
.is_some_and(|value| value.is_null()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unquoted_wikilink_relationships_are_preserved() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let entry = parse_test_entry(
|
||||
&dir,
|
||||
"book.md",
|
||||
"---\ntype: Type\nMentor: [[person/alice]]\n---\n# Book\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
entry.relationships.get("Mentor"),
|
||||
Some(&vec!["[[person/alice]]".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_parser_recovers_special_alias_items() {
|
||||
let cases = [
|
||||
@@ -133,3 +167,13 @@ fn test_alias_parser_recovers_special_alias_items() {
|
||||
assert_alias_parser_recovers(case);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_collisions_keep_frontmatter_with_last_value_winning() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\nstatus: Active\nStatus: Evergreened\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "test.md", content);
|
||||
|
||||
assert_eq!(entry.is_a, Some("Note".to_string()));
|
||||
assert_eq!(entry.status, Some("Evergreened".to_string()));
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
|
||||
}
|
||||
|
||||
fn has_gitignore_file(vault_path: &Path) -> bool {
|
||||
if vault_path.join(".gitignore").is_file() {
|
||||
return true;
|
||||
}
|
||||
|
||||
WalkDir::new(vault_path)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
@@ -43,14 +47,17 @@ fn run_git_check_ignore(vault_path: &Path, relative_paths: &[String]) -> Option<
|
||||
.spawn()
|
||||
.ok()?;
|
||||
|
||||
{
|
||||
let stdin = child.stdin.as_mut()?;
|
||||
for path in relative_paths {
|
||||
writeln!(stdin, "{path}").ok()?;
|
||||
let mut stdin = child.stdin.take()?;
|
||||
let paths = relative_paths.to_vec();
|
||||
let writer = std::thread::spawn(move || -> std::io::Result<()> {
|
||||
for path in paths {
|
||||
writeln!(stdin, "{path}")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
let output = child.wait_with_output().ok()?;
|
||||
writer.join().ok()?.ok()?;
|
||||
if output.status.success() || output.status.code() == Some(1) {
|
||||
return Some(String::from_utf8_lossy(&output.stdout).to_string());
|
||||
}
|
||||
@@ -186,6 +193,8 @@ pub fn filter_gitignored_folders(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_file(root: &Path, relative: &str, content: &str) {
|
||||
@@ -288,6 +297,34 @@ mod tests {
|
||||
assert_eq!(filtered[0].path, "notes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_large_ignored_folder_sets_without_blocking_on_git_stdout() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
init_git_repo(dir.path());
|
||||
write_file(dir.path(), ".gitignore", "generated/\n");
|
||||
|
||||
let folders = (0..6_000)
|
||||
.map(|index| FolderNode {
|
||||
name: format!("package-{index}"),
|
||||
path: format!("generated/package-{index}"),
|
||||
children: vec![],
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let vault_path = dir.path().to_path_buf();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let filtered = filter_gitignored_folders(vault_path.as_path(), folders, true);
|
||||
let _ = sender.send(filtered);
|
||||
drop(dir);
|
||||
});
|
||||
|
||||
let filtered = receiver
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("large gitignored folder filtering should not block on child stdout");
|
||||
assert!(filtered.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_no_effect_without_gitignore_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -10,6 +10,7 @@ mod ignored;
|
||||
mod image;
|
||||
mod migration;
|
||||
mod parsing;
|
||||
pub(crate) mod path_identity;
|
||||
mod rename;
|
||||
mod rename_transaction;
|
||||
mod title_sync;
|
||||
@@ -349,11 +350,7 @@ fn lookup_git_dates(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Option<(u64, u64)> {
|
||||
let rel = path
|
||||
.strip_prefix(vault_path)
|
||||
.ok()?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let rel = path_identity::vault_relative_path_string(vault_path, path).ok()?;
|
||||
git_dates.get(&rel).map(|d| (d.modified_at, d.created_at))
|
||||
}
|
||||
|
||||
@@ -462,11 +459,10 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String>
|
||||
if is_folder_tree_hidden_dir(&name) {
|
||||
continue;
|
||||
}
|
||||
let rel_path = path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let rel_path = path_identity::vault_relative_path_string(vault_root, &path)
|
||||
.unwrap_or_else(|_| {
|
||||
path_identity::normalize_path_for_identity(&path.to_string_lossy())
|
||||
});
|
||||
let children = build_tree(&path, vault_root);
|
||||
nodes.push(FolderNode {
|
||||
name,
|
||||
|
||||
@@ -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();
|
||||
|
||||
117
src-tauri/src/vault/path_identity.rs
Normal file
117
src-tauri/src/vault/path_identity.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use std::path::Path;
|
||||
|
||||
type PathText = str;
|
||||
type RelativePathText = str;
|
||||
|
||||
fn normalize_tmp_alias(path: &PathText) -> String {
|
||||
if path == "/private/tmp" {
|
||||
return "/tmp".to_string();
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("/private/tmp/") {
|
||||
return format!("/tmp/{rest}");
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
fn trim_trailing_slashes(path: String) -> String {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
if trimmed.is_empty() && path.starts_with('/') {
|
||||
"/".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_path_for_identity(path: &PathText) -> String {
|
||||
trim_trailing_slashes(normalize_tmp_alias(&path.replace('\\', "/")))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_relative_path(path: &RelativePathText) -> String {
|
||||
path.replace('\\', "/").trim_matches('/').to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn relative_path_key(path: &RelativePathText) -> String {
|
||||
normalize_relative_path(path).to_lowercase()
|
||||
}
|
||||
|
||||
pub(crate) fn has_hidden_segment(path: &RelativePathText) -> bool {
|
||||
normalize_relative_path(path)
|
||||
.split('/')
|
||||
.any(|segment| segment.starts_with('.'))
|
||||
}
|
||||
|
||||
pub(crate) fn push_unique_relative_path(
|
||||
paths: &mut Vec<String>,
|
||||
path: impl AsRef<RelativePathText>,
|
||||
) {
|
||||
let normalized = normalize_relative_path(path.as_ref());
|
||||
if normalized.is_empty() || has_hidden_segment(&normalized) {
|
||||
return;
|
||||
}
|
||||
let key = relative_path_key(&normalized);
|
||||
if !paths
|
||||
.iter()
|
||||
.any(|existing| relative_path_key(existing) == key)
|
||||
{
|
||||
paths.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn vault_relative_path_string(vault: &Path, file: &Path) -> Result<String, String> {
|
||||
let vault_path = normalize_path_for_identity(&vault.to_string_lossy());
|
||||
let file_path = normalize_path_for_identity(&file.to_string_lossy());
|
||||
if file_path == vault_path {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let prefix = format!("{vault_path}/");
|
||||
file_path
|
||||
.strip_prefix(&prefix)
|
||||
.map(normalize_relative_path)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"File {} is not inside vault {}",
|
||||
file.display(),
|
||||
vault.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn vault_relative_markdown_stem(path: &Path, vault: &Path) -> String {
|
||||
let relative = vault_relative_path_string(vault, path)
|
||||
.unwrap_or_else(|_| normalize_path_for_identity(&path.to_string_lossy()));
|
||||
relative
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&relative)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vault_relative_path_string_normalizes_tmp_alias_and_backslashes() {
|
||||
assert_eq!(
|
||||
vault_relative_path_string(
|
||||
Path::new("/private/tmp/tolaria-vault"),
|
||||
Path::new("/tmp/tolaria-vault/projects\\active.md"),
|
||||
)
|
||||
.unwrap(),
|
||||
"projects/active.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_path_key_is_case_insensitive_without_changing_output_path() {
|
||||
let mut paths = vec![];
|
||||
push_unique_relative_path(&mut paths, "Projects\\Active.md");
|
||||
push_unique_relative_path(&mut paths, "projects/active.md");
|
||||
|
||||
assert_eq!(paths, vec!["Projects/Active.md"]);
|
||||
assert_eq!(
|
||||
relative_path_key("Projects\\Active.md"),
|
||||
"projects/active.md"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use tempfile::NamedTempFile;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use super::filename_rules::validate_filename_stem;
|
||||
use super::path_identity::vault_relative_markdown_stem;
|
||||
use super::rename_transaction::RenameWorkspace;
|
||||
use crate::frontmatter::{update_frontmatter_content, FrontmatterValue};
|
||||
|
||||
@@ -211,12 +212,7 @@ fn update_note_title_in_content(content: &str, new_title: &str) -> String {
|
||||
|
||||
/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review").
|
||||
fn to_path_stem(path: &Path, vault_root: &Path) -> String {
|
||||
let relative = path.strip_prefix(vault_root).unwrap_or(path);
|
||||
let normalized = relative.to_string_lossy().replace('\\', "/");
|
||||
normalized
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(&normalized)
|
||||
.to_string()
|
||||
vault_relative_markdown_stem(path, vault_root)
|
||||
}
|
||||
|
||||
pub(crate) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
|
||||
@@ -501,7 +497,7 @@ pub struct DetectedRename {
|
||||
|
||||
/// Detect renamed files by comparing working tree against HEAD using git diff.
|
||||
pub fn detect_renames(vault: &Path) -> Result<Vec<DetectedRename>, String> {
|
||||
let output = crate::hidden_command("git")
|
||||
let output = crate::git::git_command()
|
||||
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
@@ -601,6 +597,27 @@ mod tests {
|
||||
vault.join(relative_path)
|
||||
}
|
||||
|
||||
fn run_git(vault: &Path, args: &[&str]) {
|
||||
let output = crate::hidden_command("git")
|
||||
.args(args)
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"git {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn init_git_repo_with_quoted_paths(vault: &Path) {
|
||||
run_git(vault, &["init"]);
|
||||
run_git(vault, &["config", "user.email", "test@test.com"]);
|
||||
run_git(vault, &["config", "user.name", "Test"]);
|
||||
run_git(vault, &["config", "core.quotePath", "true"]);
|
||||
}
|
||||
|
||||
fn assert_rename_note_filename_error<P>(
|
||||
new_filename_stem: impl AsRef<str>,
|
||||
existing_destination: Option<P>,
|
||||
@@ -710,6 +727,36 @@ mod tests {
|
||||
assert_unicode_rename_frontmatter(&result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_renames_preserves_chinese_markdown_paths() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
init_git_repo_with_quoted_paths(vault);
|
||||
create_test_file(vault, "旧名.md", "# 旧名\n");
|
||||
run_git(vault, &["add", "旧名.md"]);
|
||||
run_git(vault, &["commit", "-m", "add chinese note"]);
|
||||
fs::rename(vault.join("旧名.md"), vault.join("新名.md")).unwrap();
|
||||
run_git(vault, &["add", "-A"]);
|
||||
|
||||
let renames = detect_renames(vault).unwrap();
|
||||
|
||||
assert_eq!(renames.len(), 1);
|
||||
assert_eq!(renames[0].old_path, "旧名.md");
|
||||
assert_eq!(renames[0].new_path, "新名.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_stem_normalizes_tmp_aliases_and_separators() {
|
||||
assert_eq!(
|
||||
to_path_stem(
|
||||
Path::new("/tmp/tolaria-vault/projects\\weekly-review.md"),
|
||||
Path::new("/private/tmp/tolaria-vault")
|
||||
),
|
||||
"projects/weekly-review"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_basic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -5,6 +5,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
use super::VaultEntry;
|
||||
@@ -291,7 +292,11 @@ pub fn save_view(
|
||||
/// Delete a view file at `vault_path/views/{filename}`.
|
||||
pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> {
|
||||
let path = vault_path.join("views").join(filename);
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to delete view: {}", e))
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(format!("Failed to delete view: {}", error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluate a view definition against vault entries, returning indices of matching entries.
|
||||
@@ -543,67 +548,65 @@ fn evaluate_scalar_op(
|
||||
raw_value: &Option<serde_yaml::Value>,
|
||||
) -> bool {
|
||||
match op {
|
||||
FilterOp::Equals => match (field_value, cond_value) {
|
||||
(Some(f), Some(v)) => f.eq_ignore_ascii_case(v),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::NotEquals => match (field_value, cond_value) {
|
||||
(Some(f), Some(v)) => !f.eq_ignore_ascii_case(v),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
},
|
||||
FilterOp::Contains => match (field_value, cond_value) {
|
||||
(Some(f), Some(v)) => f.to_lowercase().contains(&v.to_lowercase()),
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::NotContains => match (field_value, cond_value) {
|
||||
(Some(f), Some(v)) => !f.to_lowercase().contains(&v.to_lowercase()),
|
||||
(None, _) => true,
|
||||
_ => true,
|
||||
},
|
||||
FilterOp::AnyOf => {
|
||||
let values = raw_value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
match field_value {
|
||||
Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
FilterOp::NoneOf => {
|
||||
let values = raw_value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
match field_value {
|
||||
Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
FilterOp::Equals => scalar_equals(field_value, cond_value),
|
||||
FilterOp::NotEquals => !scalar_equals(field_value, cond_value),
|
||||
FilterOp::Contains => scalar_contains(field_value, cond_value),
|
||||
FilterOp::NotContains => !scalar_contains(field_value, cond_value),
|
||||
FilterOp::AnyOf => scalar_matches_any(field_value, raw_value),
|
||||
FilterOp::NoneOf => !scalar_matches_any(field_value, raw_value),
|
||||
FilterOp::IsEmpty => field_value.map_or(true, str::is_empty),
|
||||
FilterOp::IsNotEmpty => field_value.is_some_and(|s| !s.is_empty()),
|
||||
FilterOp::Before => match (field_value, cond_value) {
|
||||
(Some(f), Some(v)) => match (
|
||||
parse_date_filter_timestamp(f, Utc::now()),
|
||||
parse_date_filter_timestamp(v, Utc::now()),
|
||||
) {
|
||||
(Some(field_ts), Some(target_ts)) => field_ts < target_ts,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::After => match (field_value, cond_value) {
|
||||
(Some(f), Some(v)) => match (
|
||||
parse_date_filter_timestamp(f, Utc::now()),
|
||||
parse_date_filter_timestamp(v, Utc::now()),
|
||||
) {
|
||||
(Some(field_ts), Some(target_ts)) => field_ts > target_ts,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::Before => {
|
||||
scalar_date_compare(field_value, cond_value, |field, target| field < target)
|
||||
}
|
||||
FilterOp::After => {
|
||||
scalar_date_compare(field_value, cond_value, |field, target| field > target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_equals(field_value: Option<&str>, cond_value: Option<&str>) -> bool {
|
||||
match (field_value, cond_value) {
|
||||
(Some(field), Some(value)) => field.eq_ignore_ascii_case(value),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_contains(field_value: Option<&str>, cond_value: Option<&str>) -> bool {
|
||||
match (field_value, cond_value) {
|
||||
(Some(field), Some(value)) => field.to_lowercase().contains(&value.to_lowercase()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_matches_any(field_value: Option<&str>, raw_value: &Option<serde_yaml::Value>) -> bool {
|
||||
let Some(field) = field_value else {
|
||||
return false;
|
||||
};
|
||||
raw_value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|value| field.eq_ignore_ascii_case(value))
|
||||
}
|
||||
|
||||
fn scalar_date_compare(
|
||||
field_value: Option<&str>,
|
||||
cond_value: Option<&str>,
|
||||
predicate: impl FnOnce(i64, i64) -> bool,
|
||||
) -> bool {
|
||||
let (Some(field), Some(value)) = (field_value, cond_value) else {
|
||||
return false;
|
||||
};
|
||||
let reference = Utc::now();
|
||||
match (
|
||||
parse_date_filter_timestamp(field, reference),
|
||||
parse_date_filter_timestamp(value, reference),
|
||||
) {
|
||||
(Some(field_ts), Some(target_ts)) => predicate(field_ts, target_ts),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1067,6 +1070,25 @@ filters:
|
||||
assert_eq!(views.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_view_treats_missing_file_as_deleted() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("views")).unwrap();
|
||||
|
||||
delete_view(dir.path(), "missing.yml").unwrap();
|
||||
|
||||
assert!(scan_views(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_view_treats_missing_views_directory_as_deleted() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
delete_view(dir.path(), "missing.yml").unwrap();
|
||||
|
||||
assert!(scan_views(dir.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_read_view_with_emoji_icon() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -7,7 +7,7 @@ use tauri::{
|
||||
WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const WINDOW_STATE_FILE: &str = "window-state.json";
|
||||
const MIN_WINDOW_WIDTH: u32 = 480;
|
||||
const MIN_WINDOW_HEIGHT: u32 = 400;
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"trafficLightPosition": {
|
||||
"x": 18,
|
||||
"y": 24
|
||||
},
|
||||
"hiddenTitle": true,
|
||||
"backgroundColor": "#F7F6F3",
|
||||
"dragDropEnabled": true
|
||||
|
||||
@@ -26,15 +26,23 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app:has(.app__note-list) .app__sidebar > * {
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
.app__note-list {
|
||||
flex: 0 0 auto;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
background: var(--surface-sidebar);
|
||||
}
|
||||
|
||||
.app__note-list > * {
|
||||
flex: 1;
|
||||
border-left: 1px solid var(--sidebar-border);
|
||||
}
|
||||
|
||||
.app__editor {
|
||||
|
||||
@@ -257,13 +257,20 @@ function getHeaderForNoteList(noteListContainer: HTMLElement) {
|
||||
return within(noteListContainer.parentElement as HTMLElement).getByRole('heading', { level: 3 })
|
||||
}
|
||||
|
||||
async function enterNeighborhood(noteListContainer: HTMLElement, title: string) {
|
||||
async function clickNoteListItem(noteListContainer: HTMLElement, title: string, options?: MouseEventInit) {
|
||||
await waitFor(() => {
|
||||
expect(within(noteListContainer).getByText(title)).toBeInTheDocument()
|
||||
})
|
||||
await act(async () => {
|
||||
fireEvent.click(within(noteListContainer).getByText(title), { metaKey: true })
|
||||
fireEvent.click(within(noteListContainer).getByText(title), options)
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function enterNeighborhood(noteListContainer: HTMLElement, title: string) {
|
||||
await clickNoteListItem(noteListContainer, title, { metaKey: true })
|
||||
}
|
||||
|
||||
async function pressEscape() {
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
@@ -304,7 +311,7 @@ function resetMockCommandResults() {
|
||||
}
|
||||
|
||||
function resolveMockCommandResult(cmd: string, args?: unknown) {
|
||||
const result = mockCommandResults[cmd]
|
||||
const result = Reflect.get(mockCommandResults, cmd) as unknown
|
||||
return typeof result === 'function'
|
||||
? (result as (input?: unknown) => unknown)(args)
|
||||
: result ?? null
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -645,6 +654,39 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows immediate feedback while a menu-driven update check is pending', async () => {
|
||||
let resolveUpdate: ((result: { kind: 'up-to-date' }) => void) | null = null
|
||||
const checkForUpdates = vi.fn(() => new Promise<{ kind: 'up-to-date' }>((resolve) => {
|
||||
resolveUpdate = resolve
|
||||
}))
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(checkForUpdates))
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
window.__laputaTest?.dispatchBrowserMenuCommand?.('app-check-for-updates')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Checking for updates...')).toBeInTheDocument()
|
||||
})
|
||||
expect(checkForUpdates).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () => {
|
||||
resolveUpdate?.({ kind: 'up-to-date' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No newer stable update is available right now')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
|
||||
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
|
||||
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)
|
||||
@@ -660,8 +702,8 @@ describe('App', () => {
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('AI is ready')).toBeInTheDocument()
|
||||
}, { timeout: SLOW_APP_READY_TIMEOUT_MS })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
|
||||
@@ -675,7 +717,7 @@ describe('App', () => {
|
||||
expect(screen.getByText('Manage External AI Tools')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('mcp-setup-dialog')).toBeInTheDocument()
|
||||
expect(screen.queryByText('AI agents ready')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('AI is ready')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('routes right-panel AI chat messages to the selected default agent', async () => {
|
||||
@@ -834,7 +876,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 +895,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 +972,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 +1106,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 +1169,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 +1183,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 +1210,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 +1237,7 @@ describe('App', () => {
|
||||
})
|
||||
|
||||
expect(window.__laputaTest?.activeTabPath).toBe('/vault/gamma.md')
|
||||
})
|
||||
}, 10_000)
|
||||
|
||||
it('renders status bar', async () => {
|
||||
render(<App />)
|
||||
|
||||
61
src/App.tsx
61
src/App.tsx
@@ -16,7 +16,6 @@ import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsPanel } from './components/SettingsPanel'
|
||||
import { CloneVaultModal } from './components/CloneVaultModal'
|
||||
import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { AppLoadingSkeleton } from './components/AppLoadingSkeleton'
|
||||
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
|
||||
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
|
||||
import { FeedbackDialog } from './components/FeedbackDialog'
|
||||
@@ -102,6 +101,7 @@ import {
|
||||
getBrowserLanguagePreferences,
|
||||
resolveEffectiveLocale,
|
||||
serializeUiLanguagePreference,
|
||||
translate,
|
||||
type UiLanguagePreference,
|
||||
} from './lib/i18n'
|
||||
import { normalizeReleaseChannel } from './lib/releaseChannel'
|
||||
@@ -109,6 +109,7 @@ import {
|
||||
buildVaultAiGuidanceRefreshKey,
|
||||
} from './lib/vaultAiGuidance'
|
||||
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
||||
import { isActiveVaultUnavailableError } from './utils/vaultErrors'
|
||||
import { hasNoteIconValue } from './utils/noteIcon'
|
||||
import { filenameStemToTitle } from './utils/noteTitle'
|
||||
import {
|
||||
@@ -125,6 +126,7 @@ import {
|
||||
isExplicitOrganizationEnabled,
|
||||
sanitizeSelectionForOrganization,
|
||||
} from './utils/organizationWorkflow'
|
||||
import { requestPlainTextPaste } from './utils/plainTextPaste'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -375,6 +377,7 @@ function App() {
|
||||
}, [resolvedPath, setToastMessage])
|
||||
|
||||
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
|
||||
const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null
|
||||
const {
|
||||
markInternalWrite: markRecentVaultWrite,
|
||||
filterExternalPaths: filterExternalVaultPaths,
|
||||
@@ -578,6 +581,9 @@ function App() {
|
||||
markRecentVaultWrite(path)
|
||||
vault.loadModifiedFiles()
|
||||
}, [markRecentVaultWrite, vault])
|
||||
const handleMissingActiveVault = useCallback(() => {
|
||||
if (!noteWindowParams && resolvedPath) vault.markVaultUnavailable(resolvedPath)
|
||||
}, [noteWindowParams, resolvedPath, vault])
|
||||
|
||||
const notes = useNoteActions({
|
||||
addEntry: vault.addEntry,
|
||||
@@ -596,6 +602,7 @@ function App() {
|
||||
unsavedPaths: vault.unsavedPaths,
|
||||
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
|
||||
onNewNotePersisted: handleCreatedVaultEntryPersisted,
|
||||
onMissingActiveVault: handleMissingActiveVault,
|
||||
onTypeStateChanged: async () => { await vault.reloadVault() },
|
||||
replaceEntry: vault.replaceEntry,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
@@ -1169,7 +1176,15 @@ function App() {
|
||||
|
||||
const handleDeleteView = useCallback(async (filename: string) => {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
try {
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
} catch (err) {
|
||||
if (isActiveVaultUnavailableError(err)) {
|
||||
vault.markVaultUnavailable(resolvedPath)
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
await vault.reloadViews()
|
||||
await vault.reloadVault()
|
||||
vault.reloadFolders()
|
||||
@@ -1230,6 +1245,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)
|
||||
@@ -1262,6 +1281,7 @@ function App() {
|
||||
await restartApp()
|
||||
return
|
||||
}
|
||||
setToastMessage(translate(appLocale, 'update.checking'))
|
||||
const result = await updateActions.checkForUpdates()
|
||||
if (result.kind === 'up-to-date') {
|
||||
const checkedChannel = normalizeReleaseChannel(settings.release_channel)
|
||||
@@ -1271,7 +1291,7 @@ function App() {
|
||||
} else {
|
||||
setToastMessage(result.message)
|
||||
}
|
||||
}, [settings.release_channel, updateActions, updateStatus.state, setToastMessage])
|
||||
}, [appLocale, settings.release_channel, updateActions, updateStatus.state, setToastMessage])
|
||||
|
||||
const handleRepairVault = useCallback(async () => {
|
||||
if (!resolvedPath) return
|
||||
@@ -1403,6 +1423,11 @@ function App() {
|
||||
const replaceInNoteCommand = useCallback(() => {
|
||||
findInNoteRef.current?.({ replace: true })
|
||||
}, [])
|
||||
const pastePlainTextCommand = useCallback(() => {
|
||||
void requestPlainTextPaste().catch((error) => {
|
||||
console.warn('[paste] Failed to paste plain text:', error)
|
||||
})
|
||||
}, [])
|
||||
const removeActiveVaultCommand = useCallback(() => {
|
||||
vaultSwitcher.removeVault(vaultSwitcher.vaultPath)
|
||||
}, [vaultSwitcher])
|
||||
@@ -1492,6 +1517,7 @@ function App() {
|
||||
onSearch: dialogs.openSearch,
|
||||
onFindInNote: findInNoteCommand,
|
||||
onReplaceInNote: activeDeletedFile ? undefined : replaceInNoteCommand,
|
||||
onPastePlainText: pastePlainTextCommand,
|
||||
onCreateNote: notes.handleCreateNoteImmediate,
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: appSave.handleSave,
|
||||
@@ -1607,14 +1633,11 @@ function App() {
|
||||
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
|
||||
}, [onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
|
||||
|
||||
// Show loading skeleton while checking vault (skip for note windows)
|
||||
if (!noteWindowParams && onboarding.state.status === 'loading') {
|
||||
return <AppLoadingSkeleton />
|
||||
}
|
||||
const isStartupLoading = !noteWindowParams && onboarding.state.status === 'loading'
|
||||
|
||||
// Show telemetry consent dialog on first launch (skip for note windows).
|
||||
// After the user answers, the next render can continue into onboarding.
|
||||
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
|
||||
if (!noteWindowParams && !isStartupLoading && settingsLoaded && settings.telemetry_consent === null) {
|
||||
return (
|
||||
<TelemetryConsentDialog
|
||||
onAccept={() => {
|
||||
@@ -1629,8 +1652,17 @@ function App() {
|
||||
}
|
||||
|
||||
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
||||
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = shouldResumeFreshStartOnboarding
|
||||
if (!noteWindowParams && (runtimeMissingVaultPath || onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
||||
const welcomeOnboarding = runtimeMissingVaultPath
|
||||
? {
|
||||
...onboarding,
|
||||
state: {
|
||||
status: 'vault-missing' as const,
|
||||
vaultPath: runtimeMissingVaultPath,
|
||||
defaultPath: vaultSwitcher.defaultPath || runtimeMissingVaultPath,
|
||||
},
|
||||
}
|
||||
: shouldResumeFreshStartOnboarding
|
||||
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
|
||||
: onboarding
|
||||
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
|
||||
@@ -1653,7 +1685,7 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
const isVaultContentLoading = !noteWindowParams && onboarding.state.status === 'ready' && vault.isLoading
|
||||
const isVaultContentLoading = !noteWindowParams && (isStartupLoading || (onboarding.state.status === 'ready' && vault.isLoading))
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
@@ -1661,7 +1693,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} onCollapse={handleCollapseSidebar} onGoBack={handleGoBack} onGoForward={handleGoForward} canGoBack={canGoBack} canGoForward={canGoForward} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -1683,7 +1715,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}
|
||||
@@ -1695,6 +1727,7 @@ function App() {
|
||||
onToggleInspector={handleToggleInspector}
|
||||
inspectorWidth={layout.inspectorWidth}
|
||||
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
|
||||
defaultAiTarget={aiAgentPreferences.defaultAiTarget}
|
||||
defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={setToastMessage}
|
||||
@@ -1748,7 +1781,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} defaultAiTarget={settings.default_ai_target ?? undefined} aiModelProviders={settings.ai_model_providers ?? []} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onSetDefaultAiTarget={aiAgentPreferences.setDefaultAiTarget} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} />
|
||||
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('AiAgentsOnboardingPrompt', () => {
|
||||
claude_code: { status: 'installed', version: '1.0.20' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('AI agents ready')).toBeInTheDocument()
|
||||
expect(screen.getByText('AI is ready')).toBeInTheDocument()
|
||||
expectMissingAgentInstallLinks()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue')
|
||||
})
|
||||
@@ -66,12 +66,12 @@ describe('AiAgentsOnboardingPrompt', () => {
|
||||
it('shows the missing state when no agents are installed', () => {
|
||||
renderPrompt()
|
||||
|
||||
expect(screen.getByText('No AI agents detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('Choose how Tolaria should use AI')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('claude-onboarding-screen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Claude Code not detected')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-install-claude_code')).toBeInTheDocument()
|
||||
expectMissingAgentInstallLinks()
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Continue without it')
|
||||
expect(screen.getByTestId('ai-agents-onboarding-continue')).toHaveTextContent('Set up later')
|
||||
})
|
||||
|
||||
it('opens the agent install links', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { ArrowUpRight, Bot, CheckCircle2, Cloud, HardDrive, Loader2, Terminal } from 'lucide-react'
|
||||
import {
|
||||
AI_AGENT_DEFINITIONS,
|
||||
getAiAgentDefinition,
|
||||
@@ -20,7 +20,7 @@ function getPromptCopy(statuses: AiAgentsStatus) {
|
||||
if (isAiAgentsStatusChecking(statuses)) {
|
||||
return {
|
||||
accentClassName: 'bg-muted text-muted-foreground',
|
||||
description: 'Checking which AI agents are available on this machine.',
|
||||
description: 'Checking coding agents. You can also use a local model or API provider.',
|
||||
icon: <Loader2 className="size-7 animate-spin" />,
|
||||
title: 'Checking AI agents',
|
||||
}
|
||||
@@ -29,20 +29,54 @@ function getPromptCopy(statuses: AiAgentsStatus) {
|
||||
if (!hasAnyInstalledAiAgent(statuses)) {
|
||||
return {
|
||||
accentClassName: 'bg-[var(--feedback-warning-bg)] text-[var(--feedback-warning-text)]',
|
||||
description: 'Tolaria works best with a local CLI AI agent installed.',
|
||||
description: 'Connect a local model, an API provider, or a desktop coding agent.',
|
||||
icon: <Bot className="size-7" />,
|
||||
title: 'No AI agents detected',
|
||||
title: 'Choose how Tolaria should use AI',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accentClassName: 'bg-[var(--feedback-success-bg)] text-[var(--feedback-success-text)]',
|
||||
description: 'Your AI agents are ready to use in Tolaria.',
|
||||
description: 'You can use the detected coding agents, or add local/API models in Settings.',
|
||||
icon: <CheckCircle2 className="size-7" />,
|
||||
title: 'AI agents ready',
|
||||
title: 'AI is ready',
|
||||
}
|
||||
}
|
||||
|
||||
function AiModeChoices() {
|
||||
const choices = [
|
||||
{
|
||||
icon: <HardDrive className="size-4" />,
|
||||
title: 'Local model',
|
||||
description: 'Use Ollama, LM Studio, or another local OpenAI-compatible endpoint. API keys are usually not needed.',
|
||||
},
|
||||
{
|
||||
icon: <Cloud className="size-4" />,
|
||||
title: 'API provider',
|
||||
description: 'Use OpenAI, Anthropic, OpenRouter, or a gateway. API keys are read from environment variables, not saved in settings.',
|
||||
},
|
||||
{
|
||||
icon: <Terminal className="size-4" />,
|
||||
title: 'Coding agent',
|
||||
description: 'Use Claude Code, Codex, OpenCode, Gemini CLI, or Pi for tool-capable vault editing on desktop.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{choices.map((choice) => (
|
||||
<div key={choice.title} className="rounded-lg border border-border bg-muted/20 p-3 text-left">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
{choice.icon}
|
||||
{choice.title}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-muted-foreground">{choice.description}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -104,6 +138,7 @@ export function AiAgentsOnboardingPrompt({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<AiModeChoices />
|
||||
{showLegacyClaudeCompatibility ? (
|
||||
<div
|
||||
className="rounded-lg border border-[var(--feedback-warning-border)] bg-[var(--feedback-warning-bg)] px-4 py-3 text-left"
|
||||
@@ -138,7 +173,7 @@ export function AiAgentsOnboardingPrompt({
|
||||
disabled={isAiAgentsStatusChecking(statuses)}
|
||||
data-testid={showLegacyClaudeCompatibility ? 'claude-onboarding-continue' : undefined}
|
||||
>
|
||||
{hasAnyInstalledAiAgent(statuses) ? 'Continue' : 'Continue without it'}
|
||||
{hasAnyInstalledAiAgent(statuses) ? 'Continue' : 'Set up later'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
|
||||
@@ -197,6 +197,18 @@ describe('AiPanel', () => {
|
||||
expect(screen.getByTestId('ai-panel')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps long AI agent drafts inside a scrollable composer while keeping send visible', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
editor.textContent = Array.from({ length: 40 }, (_, index) => `Line ${index + 1}`).join('\n')
|
||||
fireEvent.input(editor)
|
||||
|
||||
expect(editor).toHaveClass('max-h-[160px]', 'overflow-y-auto', 'overscroll-contain')
|
||||
expect(editor).toHaveStyle({ maxHeight: '160px', overflowY: 'auto' })
|
||||
expect(screen.getByTestId('agent-send')).toBeVisible()
|
||||
})
|
||||
|
||||
it('calls onClose when close button is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AiAgentId,
|
||||
type AiAgentReadiness,
|
||||
} from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import { type NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
@@ -25,6 +26,7 @@ interface AiPanelProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
locale?: AppLocale
|
||||
@@ -47,6 +49,7 @@ interface AiPanelViewProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
locale?: AppLocale
|
||||
@@ -64,6 +67,7 @@ export function AiPanelView({
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
locale = 'en',
|
||||
@@ -75,7 +79,9 @@ export function AiPanelView({
|
||||
?? readinessFromReadyFlag(providedDefaultAiAgentReady)
|
||||
const inputRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
const agentLabel = getAiAgentDefinition(defaultAiAgent).label
|
||||
const activeTarget = defaultAiTarget
|
||||
const agentLabel = activeTarget?.label ?? getAiAgentDefinition(defaultAiAgent).label
|
||||
const targetKind = activeTarget?.kind ?? 'agent'
|
||||
const {
|
||||
agent,
|
||||
input,
|
||||
@@ -118,6 +124,7 @@ export function AiPanelView({
|
||||
<AiPanelHeader
|
||||
agentLabel={agentLabel}
|
||||
agentReadiness={defaultAiAgentReadiness}
|
||||
targetKind={targetKind}
|
||||
locale={locale}
|
||||
permissionMode={permissionMode}
|
||||
permissionModeDisabled={isActive}
|
||||
@@ -159,6 +166,7 @@ export function AiPanel({
|
||||
onOpenNote,
|
||||
onUnsupportedAiPaste,
|
||||
defaultAiAgent: providedDefaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady,
|
||||
locale = 'en',
|
||||
@@ -178,6 +186,7 @@ export function AiPanel({
|
||||
const controller = useAiPanelController({
|
||||
vaultPath,
|
||||
defaultAiAgent: providedDefaultAiAgent ?? DEFAULT_AI_AGENT,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReady: providedDefaultAiAgentReady ?? true,
|
||||
defaultAiAgentReadiness,
|
||||
activeEntry,
|
||||
@@ -200,6 +209,7 @@ export function AiPanel({
|
||||
onOpenNote={onOpenNote}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
defaultAiAgent={providedDefaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={providedDefaultAiAgentReady}
|
||||
locale={locale}
|
||||
|
||||
55
src/components/AiPanelChrome.performance.test.tsx
Normal file
55
src/components/AiPanelChrome.performance.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AiPanelMessageHistory } from './AiPanelChrome'
|
||||
|
||||
const aiMessageRender = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./AiMessage', () => ({
|
||||
AiMessage: (props: { id?: string; response: string }) => {
|
||||
aiMessageRender(props.id)
|
||||
return <div data-testid="ai-message">{props.response}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
function HistoryRerenderHarness() {
|
||||
const [draft, setDraft] = useState('')
|
||||
const [messages] = useState([{
|
||||
userMessage: 'Explain the note',
|
||||
actions: [],
|
||||
response: 'Here is a long answer with [[Test Note]].',
|
||||
id: 'msg-stable',
|
||||
}])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setDraft('typing')}>type</button>
|
||||
<span data-testid="draft">{draft}</span>
|
||||
<AiPanelMessageHistory
|
||||
agentLabel="Claude Code"
|
||||
agentReadiness="ready"
|
||||
messages={messages}
|
||||
isActive={false}
|
||||
onOpenNote={noop}
|
||||
onNavigateWikilink={noop}
|
||||
hasContext
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('AiPanelChrome performance', () => {
|
||||
it('keeps stable message history from re-rendering while composer state changes', () => {
|
||||
render(<HistoryRerenderHarness />)
|
||||
expect(screen.getByTestId('ai-message')).toHaveTextContent('Here is a long answer')
|
||||
expect(aiMessageRender).toHaveBeenCalledTimes(1)
|
||||
aiMessageRender.mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'type' }))
|
||||
|
||||
expect(screen.getByTestId('draft')).toHaveTextContent('typing')
|
||||
expect(aiMessageRender).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -19,6 +19,7 @@ import type { VaultEntry } from '../types'
|
||||
interface AiPanelHeaderProps {
|
||||
agentLabel: string
|
||||
agentReadiness: AiAgentReadiness
|
||||
targetKind?: 'agent' | 'api_model'
|
||||
locale?: AppLocale
|
||||
permissionMode: AiAgentPermissionMode
|
||||
permissionModeDisabled: boolean
|
||||
@@ -164,9 +165,10 @@ function AiPanelEmptyState({
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelHeader({
|
||||
export const AiPanelHeader = memo(function AiPanelHeader({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
targetKind = 'agent',
|
||||
locale = 'en',
|
||||
permissionMode,
|
||||
permissionModeDisabled,
|
||||
@@ -175,7 +177,9 @@ export function AiPanelHeader({
|
||||
onNewChat,
|
||||
}: AiPanelHeaderProps) {
|
||||
const t = createTranslator(locale)
|
||||
const modeLabel = aiAgentPermissionModeLabels(permissionMode, locale).short
|
||||
const modeLabel = targetKind === 'api_model'
|
||||
? t('ai.panel.mode.chat')
|
||||
: aiAgentPermissionModeLabels(permissionMode, locale).short
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -196,6 +200,7 @@ export function AiPanelHeader({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="h-6 w-6 p-0 [&_svg:not([class*=size-])]:size-4"
|
||||
onClick={onNewChat}
|
||||
aria-label={t('ai.panel.newChat')}
|
||||
title={t('ai.panel.newChat')}
|
||||
@@ -206,6 +211,7 @@ export function AiPanelHeader({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="h-6 w-6 p-0 [&_svg:not([class*=size-])]:size-4"
|
||||
onClick={onClose}
|
||||
aria-label={t('ai.panel.close')}
|
||||
title={t('ai.panel.close')}
|
||||
@@ -213,15 +219,21 @@ export function AiPanelHeader({
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<AiPermissionModeToggle
|
||||
value={permissionMode}
|
||||
locale={locale}
|
||||
disabled={permissionModeDisabled}
|
||||
onChange={onPermissionModeChange}
|
||||
/>
|
||||
{targetKind === 'agent' ? (
|
||||
<AiPermissionModeToggle
|
||||
value={permissionMode}
|
||||
locale={locale}
|
||||
disabled={permissionModeDisabled}
|
||||
onChange={onPermissionModeChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border bg-muted px-3 py-2 text-[11px] leading-5 text-muted-foreground">
|
||||
{t('ai.panel.mode.chatDescription')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function AiPermissionModeToggle({
|
||||
value,
|
||||
@@ -277,7 +289,11 @@ function AiPermissionModeToggle({
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: AiPanelContextBarProps) {
|
||||
export const AiPanelContextBar = memo(function AiPanelContextBar({
|
||||
activeEntry,
|
||||
linkedCount,
|
||||
locale = 'en',
|
||||
}: AiPanelContextBarProps) {
|
||||
const t = createTranslator(locale)
|
||||
|
||||
return (
|
||||
@@ -293,9 +309,9 @@ export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: A
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function AiPanelMessageHistory({
|
||||
export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
|
||||
agentLabel,
|
||||
agentReadiness,
|
||||
locale = 'en',
|
||||
@@ -332,7 +348,7 @@ export function AiPanelMessageHistory({
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function AiPanelComposer({
|
||||
entries,
|
||||
@@ -365,7 +381,7 @@ export function AiPanelComposer({
|
||||
style={{ padding: '8px 12px' }}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<WikilinkChatInput
|
||||
entries={entries}
|
||||
value={input}
|
||||
@@ -375,6 +391,8 @@ export function AiPanelComposer({
|
||||
disabled={composerDisabled}
|
||||
placeholder={placeholder}
|
||||
inputRef={inputRef}
|
||||
editorClassName="max-h-[160px] overflow-y-auto overscroll-contain"
|
||||
editorStyle={{ maxHeight: 160, overflowY: 'auto', overscrollBehavior: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
350
src/components/AiProviderSettings.tsx
Normal file
350
src/components/AiProviderSettings.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DEFAULT_MODEL_CAPABILITIES,
|
||||
aiModelProviderCatalog,
|
||||
aiModelProviderCatalogEntry,
|
||||
configuredModelTargets,
|
||||
isLocalAiProvider,
|
||||
normalizeAiModelProviders,
|
||||
type AiModelApiKeyStorage,
|
||||
type AiModelProvider,
|
||||
type AiModelProviderKind,
|
||||
} from '../lib/aiTargets'
|
||||
import type { createTranslator } from '../lib/i18n'
|
||||
import { deleteAiModelProviderApiKey, saveAiModelProviderApiKey, testAiModelProvider } from '../utils/aiProviderSecrets'
|
||||
import { Button } from './ui/button'
|
||||
import { Input } from './ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select'
|
||||
|
||||
type Translate = ReturnType<typeof createTranslator>
|
||||
type ProviderMode = 'local' | 'api'
|
||||
type TestState = 'idle' | 'testing' | 'success'
|
||||
|
||||
interface AiProviderSettingsProps {
|
||||
t: Translate
|
||||
mode: ProviderMode
|
||||
providers: AiModelProvider[]
|
||||
onChange: (providers: AiModelProvider[]) => void
|
||||
}
|
||||
|
||||
interface ProviderDraft {
|
||||
kind: AiModelProviderKind
|
||||
name: string
|
||||
baseUrl: string
|
||||
modelId: string
|
||||
apiKeyStorage: AiModelApiKeyStorage
|
||||
apiKey: string
|
||||
apiKeyEnvVar: string
|
||||
}
|
||||
|
||||
function providerKindsForMode(mode: ProviderMode): AiModelProviderKind[] {
|
||||
return aiModelProviderCatalog()
|
||||
.filter((entry) => entry.local === (mode === 'local'))
|
||||
.map((entry) => entry.kind)
|
||||
}
|
||||
|
||||
function initialDraft(mode: ProviderMode): ProviderDraft {
|
||||
const [kind] = providerKindsForMode(mode)
|
||||
if (!kind) throw new Error(`No AI model providers are configured for ${mode} mode`)
|
||||
return draftFromProviderKind(kind)
|
||||
}
|
||||
|
||||
function draftFromProviderKind(kind: AiModelProviderKind): ProviderDraft {
|
||||
const defaults = aiModelProviderCatalogEntry(kind)
|
||||
return {
|
||||
kind,
|
||||
name: defaults.name,
|
||||
baseUrl: defaults.base_url,
|
||||
modelId: '',
|
||||
apiKeyStorage: defaults.api_key_storage,
|
||||
apiKey: '',
|
||||
apiKeyEnvVar: defaults.api_key_env_var ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function providerKindOptions(mode: ProviderMode, t: Translate): Array<{ value: AiModelProviderKind; label: string }> {
|
||||
return providerKindsForMode(mode).map((kind) => {
|
||||
const defaults = aiModelProviderCatalogEntry(kind)
|
||||
return { value: kind, label: t(defaults.label_key) }
|
||||
})
|
||||
}
|
||||
|
||||
function providerPresetPatch(kind: AiModelProviderKind): Pick<ProviderDraft, 'kind' | 'name' | 'baseUrl' | 'apiKeyStorage' | 'apiKeyEnvVar'> {
|
||||
const defaults = draftFromProviderKind(kind)
|
||||
return {
|
||||
kind,
|
||||
name: defaults.name,
|
||||
baseUrl: defaults.baseUrl,
|
||||
apiKeyStorage: defaults.apiKeyStorage,
|
||||
apiKeyEnvVar: defaults.apiKeyEnvVar,
|
||||
}
|
||||
}
|
||||
|
||||
function buildProvider(draft: ProviderDraft, providerId: string): AiModelProvider {
|
||||
return {
|
||||
id: providerId,
|
||||
name: draft.name,
|
||||
kind: draft.kind,
|
||||
base_url: draft.baseUrl || null,
|
||||
api_key_storage: draft.apiKeyStorage,
|
||||
api_key_env_var: draft.apiKeyStorage === 'env' ? draft.apiKeyEnvVar || null : null,
|
||||
headers: null,
|
||||
models: [{
|
||||
id: draft.modelId,
|
||||
display_name: null,
|
||||
context_window: null,
|
||||
max_output_tokens: null,
|
||||
capabilities: DEFAULT_MODEL_CAPABILITIES,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function providerModeTitle(mode: ProviderMode, t: Translate): string {
|
||||
return mode === 'local' ? t('settings.aiProviders.localTitle') : t('settings.aiProviders.apiTitle')
|
||||
}
|
||||
|
||||
function providerModeDescription(mode: ProviderMode, t: Translate): string {
|
||||
return mode === 'local' ? t('settings.aiProviders.localDescription') : t('settings.aiProviders.apiDescription')
|
||||
}
|
||||
|
||||
function providerStorageLabel(provider: AiModelProvider, t: Translate): string {
|
||||
if (provider.api_key_storage === 'local_file') return t('settings.aiProviders.keyLocalSaved')
|
||||
if (provider.api_key_storage === 'env' && provider.api_key_env_var) {
|
||||
return t('settings.aiProviders.keyEnvSaved', { env: provider.api_key_env_var })
|
||||
}
|
||||
return t('settings.aiProviders.noKey')
|
||||
}
|
||||
|
||||
function visibleProviders(providers: AiModelProvider[], mode: ProviderMode): AiModelProvider[] {
|
||||
return providers.filter((provider) => mode === 'local' ? isLocalAiProvider(provider) : !isLocalAiProvider(provider))
|
||||
}
|
||||
|
||||
function editableInputClassName(): string {
|
||||
return 'border-border bg-background text-foreground placeholder:text-muted-foreground/65 shadow-xs'
|
||||
}
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
type?: 'text' | 'password'
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5 text-xs font-medium text-foreground">
|
||||
<span>{label}</span>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className={editableInputClassName()}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderKindSelect({
|
||||
mode,
|
||||
t,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
mode: ProviderMode
|
||||
t: Translate
|
||||
value: AiModelProviderKind
|
||||
onChange: (value: AiModelProviderKind) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="space-y-1.5 text-xs font-medium text-foreground">
|
||||
<span>{t('settings.aiProviders.kind')}</span>
|
||||
<Select value={value} onValueChange={(next) => onChange(next as AiModelProviderKind)}>
|
||||
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerKindOptions(mode, t).map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyStorageFields({
|
||||
t,
|
||||
draft,
|
||||
updateDraft,
|
||||
}: {
|
||||
t: Translate
|
||||
draft: ProviderDraft
|
||||
updateDraft: (patch: Partial<ProviderDraft>) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<label className="space-y-1.5 text-xs font-medium text-foreground">
|
||||
<span>{t('settings.aiProviders.keyStorage')}</span>
|
||||
<Select value={draft.apiKeyStorage} onValueChange={(next) => updateDraft({ apiKeyStorage: next as AiModelApiKeyStorage })}>
|
||||
<SelectTrigger className={`h-9 ${editableInputClassName()}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local_file">{t('settings.aiProviders.keyStorage.local')}</SelectItem>
|
||||
<SelectItem value="env">{t('settings.aiProviders.keyStorage.env')}</SelectItem>
|
||||
<SelectItem value="none">{t('settings.aiProviders.keyStorage.none')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
{draft.apiKeyStorage === 'local_file' ? (
|
||||
<LabeledInput
|
||||
label={t('settings.aiProviders.key')}
|
||||
value={draft.apiKey}
|
||||
onChange={(apiKey) => updateDraft({ apiKey })}
|
||||
placeholder={t('settings.aiProviders.keyPlaceholder')}
|
||||
type="password"
|
||||
/>
|
||||
) : null}
|
||||
{draft.apiKeyStorage === 'env' ? (
|
||||
<LabeledInput
|
||||
label={t('settings.aiProviders.keyEnv')}
|
||||
value={draft.apiKeyEnvVar}
|
||||
onChange={(apiKeyEnvVar) => updateDraft({ apiKeyEnvVar })}
|
||||
placeholder={aiModelProviderCatalogEntry(draft.kind).api_key_env_var ?? ''}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderList({
|
||||
t,
|
||||
mode,
|
||||
providers,
|
||||
onRemove,
|
||||
}: {
|
||||
t: Translate
|
||||
mode: ProviderMode
|
||||
providers: AiModelProvider[]
|
||||
onRemove: (providerId: string) => void
|
||||
}) {
|
||||
const visible = visibleProviders(providers, mode)
|
||||
if (visible.length === 0) {
|
||||
return <div className="rounded-md border border-dashed border-border bg-background px-3 py-2 text-xs text-muted-foreground">{t('settings.aiProviders.empty')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{configuredModelTargets(visible).map((target) => (
|
||||
<div key={target.id} className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">{target.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{target.provider.base_url || t('settings.aiProviders.defaultEndpoint')} · {providerStorageLabel(target.provider, t)}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => onRemove(target.provider.id)}>
|
||||
{t('common.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiProviderSettings({ t, mode, providers, onChange }: AiProviderSettingsProps) {
|
||||
const [draft, setDraft] = useState<ProviderDraft>(() => initialDraft(mode))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [testState, setTestState] = useState<TestState>('idle')
|
||||
const updateDraft = (patch: Partial<ProviderDraft>) => setDraft((current) => ({ ...current, ...patch }))
|
||||
const resetTest = () => {
|
||||
setTestState('idle')
|
||||
setError(null)
|
||||
}
|
||||
const updateForm = (patch: Partial<ProviderDraft>) => {
|
||||
resetTest()
|
||||
updateDraft(patch)
|
||||
}
|
||||
const updateKind = (kind: AiModelProviderKind) => updateForm(providerPresetPatch(kind))
|
||||
const canSave = draft.name.trim() && draft.modelId.trim() && (draft.apiKeyStorage !== 'local_file' || draft.apiKey.trim())
|
||||
const apiKeyOverride = draft.apiKeyStorage === 'local_file' ? draft.apiKey : null
|
||||
|
||||
const addProvider = async () => {
|
||||
const providerId = `${draft.kind}-${Date.now().toString(36)}`
|
||||
setError(null)
|
||||
try {
|
||||
if (draft.apiKeyStorage === 'local_file') {
|
||||
await saveAiModelProviderApiKey(providerId, draft.apiKey)
|
||||
}
|
||||
onChange(normalizeAiModelProviders([...providers, buildProvider(draft, providerId)]))
|
||||
setDraft((current) => ({ ...draftFromProviderKind(current.kind), name: current.name, baseUrl: current.baseUrl }))
|
||||
setTestState('idle')
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
const testProvider = async () => {
|
||||
setError(null)
|
||||
setTestState('testing')
|
||||
try {
|
||||
await testAiModelProvider(buildProvider(draft, 'draft-provider-test'), draft.modelId, apiKeyOverride)
|
||||
setTestState('success')
|
||||
} catch (error) {
|
||||
setTestState('idle')
|
||||
setError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
const removeProvider = (providerId: string) => {
|
||||
void deleteAiModelProviderApiKey(providerId)
|
||||
onChange(providers.filter((provider) => provider.id !== providerId))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card p-3" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{providerModeTitle(mode, t)}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{providerModeDescription(mode, t)}</div>
|
||||
</div>
|
||||
<ProviderList t={t} mode={mode} providers={providers} onRemove={removeProvider} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ProviderKindSelect mode={mode} t={t} value={draft.kind} onChange={updateKind} />
|
||||
<LabeledInput label={t('settings.aiProviders.name')} value={draft.name} onChange={(name) => updateForm({ name })} />
|
||||
<LabeledInput label={t('settings.aiProviders.baseUrl')} value={draft.baseUrl} onChange={(baseUrl) => updateForm({ baseUrl })} />
|
||||
<LabeledInput label={t('settings.aiProviders.model')} value={draft.modelId} onChange={(modelId) => updateForm({ modelId })} placeholder={aiModelProviderCatalogEntry(draft.kind).default_model_id} />
|
||||
{mode === 'api' ? <ApiKeyStorageFields t={t} draft={draft} updateDraft={updateForm} /> : null}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-muted-foreground">
|
||||
{mode === 'api' ? t('settings.aiProviders.keySafetyLocal') : t('settings.aiProviders.localSafety')}
|
||||
</div>
|
||||
{testState === 'success' ? <div className="text-xs text-emerald-700">{t('settings.aiProviders.testSuccess')}</div> : null}
|
||||
{error ? <div className="text-xs text-destructive">{error}</div> : null}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" size="sm" onClick={() => void addProvider()} disabled={!canSave}>
|
||||
{mode === 'local' ? t('settings.aiProviders.addLocal') : t('settings.aiProviders.addApi')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void testProvider()}
|
||||
disabled={!canSave || testState === 'testing'}
|
||||
>
|
||||
{testState === 'testing' ? t('settings.aiProviders.testing') : t('settings.aiProviders.test')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
AlignJustify,
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Code2,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Inbox,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const SIDEBAR_GROUPS = [
|
||||
{ rows: ['62%', '48%', '58%', '52%'], count: '24px' },
|
||||
{ rows: ['72%'], count: '30px' },
|
||||
{ rows: ['34%', '42%', '38%', '56%', '50%', '44%', '39%', '46%'], count: '26px' },
|
||||
{ rows: ['38%', '52%', '32%'] },
|
||||
]
|
||||
|
||||
const NOTE_ROWS = [
|
||||
{ selected: false, title: '68%', snippet: '84%', chips: ['56px'] },
|
||||
{ selected: true, title: '62%', snippet: '78%', chips: ['64px', '54px', '72px'] },
|
||||
{ selected: false, title: '48%', snippet: '44%', chips: [] },
|
||||
{ selected: false, title: '82%', snippet: '74%', chips: ['68px'] },
|
||||
{ selected: false, title: '70%', snippet: '88%', chips: ['76px', '92px'] },
|
||||
{ selected: false, title: '58%', snippet: '66%', chips: ['64px'] },
|
||||
{ selected: false, title: '76%', snippet: '72%', chips: ['72px'] },
|
||||
]
|
||||
|
||||
const EDITOR_ACTIONS = [Star, CheckCircle2, GitBranch, Code2, AlignJustify, Sparkles, Archive, Trash2, SlidersHorizontal]
|
||||
const STATUS_LEFT = ['70px', '104px', '88px', '116px', '82px']
|
||||
const STATUS_RIGHT = ['44px', '78px', '18px', '18px']
|
||||
|
||||
type AppLoadingSkeletonProps = {
|
||||
noteListWidth?: number
|
||||
showNoteList?: boolean
|
||||
showSidebar?: boolean
|
||||
sidebarWidth?: number
|
||||
}
|
||||
|
||||
function SkeletonBar({ className, style }: { className?: string; style?: CSSProperties }) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded bg-muted', className)}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonIcon({ icon: Icon, active = false }: { icon: LucideIcon; active?: boolean }) {
|
||||
return (
|
||||
<Icon
|
||||
size={16}
|
||||
strokeWidth={1.9}
|
||||
className={cn(active ? 'text-primary' : 'text-muted-foreground', 'shrink-0 opacity-70')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRow({ icon, width, active = false, countWidth }: {
|
||||
icon: LucideIcon
|
||||
width: string
|
||||
active?: boolean
|
||||
countWidth?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center gap-2 rounded px-4 py-1.5', active && 'bg-primary/10')}
|
||||
style={active ? { boxShadow: 'inset 3px 0 0 var(--primary)' } : undefined}
|
||||
>
|
||||
<SkeletonIcon icon={icon} active={active} />
|
||||
<SkeletonBar className="h-3" style={{ width }} />
|
||||
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSkeletonGlyph() {
|
||||
return <SkeletonBar className="h-4 w-4 shrink-0 rounded-[4px]" />
|
||||
}
|
||||
|
||||
function SidebarStubRow({ width, countWidth }: {
|
||||
width: string
|
||||
countWidth?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded px-4 py-1.5">
|
||||
<SidebarSkeletonGlyph />
|
||||
<SkeletonBar className="h-3" style={{ width }} />
|
||||
{countWidth && <SkeletonBar className="h-5 rounded-full" style={{ width: countWidth }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupSkeleton({ rows, count }: { rows: string[]; count?: string }) {
|
||||
return (
|
||||
<div className="border-b border-border px-1.5 pb-2">
|
||||
<div className="flex items-center gap-1 px-4 py-2">
|
||||
<ChevronDown size={12} className="text-muted-foreground opacity-60" />
|
||||
<SkeletonBar className="h-2.5" style={{ width: '54px' }} />
|
||||
{count && <SkeletonBar className="ml-auto h-4 rounded-full" style={{ width: count }} />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{rows.map((width, index) => (
|
||||
<SidebarStubRow
|
||||
key={`${width}-${index}`}
|
||||
width={width}
|
||||
countWidth={index > 3 ? '30px' : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSkeleton() {
|
||||
return (
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
|
||||
<div className="h-[52px] shrink-0 border-b border-border" />
|
||||
<nav className="flex-1 overflow-hidden py-1">
|
||||
<div className="border-b border-border px-1.5 pb-1">
|
||||
<SidebarRow icon={Inbox} width="48%" active countWidth="30px" />
|
||||
<SidebarRow icon={FileText} width="54%" countWidth="38px" />
|
||||
<SidebarRow icon={Archive} width="42%" countWidth="32px" />
|
||||
</div>
|
||||
{SIDEBAR_GROUPS.map((group, index) => (
|
||||
<SidebarGroupSkeleton key={index} rows={group.rows} count={group.count} />
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteRowSkeleton({ selected, title, snippet, chips }: {
|
||||
selected: boolean
|
||||
title: string
|
||||
snippet: string
|
||||
chips: string[]
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn('relative border-b border-border px-5 py-4', selected && 'bg-primary/5')}
|
||||
style={selected ? { boxShadow: 'inset 3px 0 0 var(--accent-green)' } : undefined}
|
||||
>
|
||||
<SkeletonBar className="absolute right-4 top-4 h-3.5 w-3.5 rounded-sm" />
|
||||
<SkeletonBar className="mb-3 h-3.5" style={{ width: title }} />
|
||||
<div className="space-y-1.5">
|
||||
<SkeletonBar className="h-2.5" style={{ width: snippet }} />
|
||||
<SkeletonBar className="h-2.5" style={{ width: '58%' }} />
|
||||
</div>
|
||||
{chips.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{chips.map((width) => (
|
||||
<SkeletonBar key={width} className="h-5 rounded" style={{ width }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto] gap-3">
|
||||
<SkeletonBar className="h-2.5" style={{ width: '38px' }} />
|
||||
<SkeletonBar className="h-2.5" style={{ width: '72px' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteListSkeleton() {
|
||||
return (
|
||||
<section className="flex h-full flex-col overflow-hidden border-r border-border bg-card text-foreground">
|
||||
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4">
|
||||
<SkeletonBar className="h-4" style={{ width: '78px' }} />
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<SkeletonBar className="h-2.5" style={{ width: '58px' }} />
|
||||
<Search size={16} />
|
||||
<SlidersHorizontal size={16} />
|
||||
<Plus size={16} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{NOTE_ROWS.map((row, index) => (
|
||||
<NoteRowSkeleton key={index} {...row} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorSkeleton() {
|
||||
return (
|
||||
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonBar className="h-3" style={{ width: '44px' }} />
|
||||
<SkeletonBar className="h-3" style={{ width: '150px' }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-5 text-muted-foreground">
|
||||
{EDITOR_ACTIONS.map((Icon, index) => (
|
||||
<Icon key={index} size={16} strokeWidth={1.8} className="opacity-65" />
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
<article className="mx-auto flex w-full max-w-[760px] flex-1 flex-col px-10 py-16">
|
||||
<SkeletonBar className="mb-7 h-9" style={{ width: '58%' }} />
|
||||
<SkeletonBar className="mb-6 h-px w-full rounded-none" />
|
||||
<div className="space-y-4">
|
||||
<SkeletonBar className="h-4" style={{ width: '52%' }} />
|
||||
<SkeletonBar className="h-4" style={{ width: '92%' }} />
|
||||
<SkeletonBar className="h-4" style={{ width: '82%' }} />
|
||||
<SkeletonBar className="h-4" style={{ width: '88%' }} />
|
||||
<SkeletonBar className="h-4" style={{ width: '74%' }} />
|
||||
</div>
|
||||
<SkeletonBar className="my-10 h-px w-full rounded-none" />
|
||||
<div className="space-y-4">
|
||||
<SkeletonBar className="h-4" style={{ width: '22%' }} />
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div key={index} className="flex items-center gap-4 pl-4">
|
||||
<SkeletonBar className="h-2.5 w-2.5 rounded-full bg-primary/70" />
|
||||
<SkeletonBar className="h-4" style={{ width: index === 1 ? '54%' : '44%' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusSkeleton() {
|
||||
return (
|
||||
<footer
|
||||
className="flex shrink-0 items-center justify-between border-t border-border bg-sidebar px-2 text-muted-foreground"
|
||||
style={{ height: 30 }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{STATUS_LEFT.map((width, index) => (
|
||||
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{STATUS_RIGHT.map((width, index) => (
|
||||
<SkeletonBar key={`${width}-${index}`} className="h-3" style={{ width }} />
|
||||
))}
|
||||
<Settings size={14} className="opacity-60" />
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppLoadingSkeleton({
|
||||
noteListWidth = 350,
|
||||
showNoteList = true,
|
||||
showSidebar = true,
|
||||
sidebarWidth = 250,
|
||||
}: AppLoadingSkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
data-testid="vault-loading-skeleton"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="sr-only">Loading vault</span>
|
||||
<div className="app" aria-hidden="true">
|
||||
{showSidebar && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: sidebarWidth }}>
|
||||
<SidebarSkeleton />
|
||||
</div>
|
||||
<div className="w-px shrink-0 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{showNoteList && (
|
||||
<>
|
||||
<div className="app__note-list" style={{ width: noteListWidth }}>
|
||||
<NoteListSkeleton />
|
||||
</div>
|
||||
<div className="w-px shrink-0 bg-border" />
|
||||
</>
|
||||
)}
|
||||
<div className="app__editor">
|
||||
<EditorSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
<StatusSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act, within } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
@@ -179,16 +179,18 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
|
||||
expect(screen.getByText('test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders emoji note icons in the breadcrumb title', () => {
|
||||
it('does not render emoji note icons in the breadcrumb filename', () => {
|
||||
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
|
||||
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
|
||||
expect(screen.getByTestId('breadcrumb-note-icon')).toHaveTextContent('🚀')
|
||||
expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test')
|
||||
expect(screen.queryByText('🚀')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Phosphor note icons in the breadcrumb title', () => {
|
||||
it('does not render Phosphor note icons in the breadcrumb filename', () => {
|
||||
const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' }
|
||||
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} />)
|
||||
expect(screen.getByTestId('breadcrumb-note-icon').tagName.toLowerCase()).toBe('svg')
|
||||
expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test')
|
||||
expect(screen.queryByTestId('breadcrumb-note-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to "Note" when isA is null', () => {
|
||||
@@ -284,6 +286,51 @@ 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('end-aligns toolbar action tooltips so zoomed windows keep them inside the right edge', async () => {
|
||||
render(
|
||||
<BreadcrumbBar
|
||||
entry={baseEntry}
|
||||
{...defaultProps}
|
||||
onToggleFavorite={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
fireEvent.focus(screen.getByRole('button', { name: 'Add to favorites' }))
|
||||
})
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip')
|
||||
expect(document.querySelector('[data-slot="tooltip-content"]')).toHaveAttribute('data-align', 'end')
|
||||
expect(tooltip).toHaveTextContent('Add to favorites')
|
||||
})
|
||||
|
||||
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 +338,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', () => {
|
||||
@@ -349,3 +425,54 @@ describe('BreadcrumbBar — note width toggle', () => {
|
||||
expect(onToggleNoteWidth).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — table of contents toggle', () => {
|
||||
it('shows the table of contents action and calls the toggle handler', () => {
|
||||
const onToggleTableOfContents = vi.fn()
|
||||
render(
|
||||
<BreadcrumbBar
|
||||
entry={baseEntry}
|
||||
{...defaultProps}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
|
||||
|
||||
expect(onToggleTableOfContents).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the close label while the table of contents panel is active', () => {
|
||||
render(
|
||||
<BreadcrumbBar
|
||||
entry={baseEntry}
|
||||
{...defaultProps}
|
||||
showTableOfContents
|
||||
onToggleTableOfContents={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Close table of contents' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers the table of contents action from the overflow menu', async () => {
|
||||
const onToggleTableOfContents = vi.fn()
|
||||
render(
|
||||
<BreadcrumbBar
|
||||
entry={baseEntry}
|
||||
{...defaultProps}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'More note actions' }), {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
})
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open table of contents' }))
|
||||
|
||||
expect(onToggleTableOfContents).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,18 @@ 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,
|
||||
ListBullets,
|
||||
SidebarSimple,
|
||||
Trash,
|
||||
Archive,
|
||||
ArrowUUpLeft,
|
||||
@@ -22,8 +29,8 @@ import {
|
||||
ArrowsClockwise,
|
||||
ArrowsInLineHorizontal,
|
||||
ArrowsOutLineHorizontal,
|
||||
DotsThree,
|
||||
} from '@phosphor-icons/react'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { slugify } from '../hooks/useNoteCreation'
|
||||
import { useDragRegion } from '../hooks/useDragRegion'
|
||||
|
||||
@@ -40,6 +47,8 @@ interface BreadcrumbBarProps {
|
||||
forceRawMode?: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
showTableOfContents?: boolean
|
||||
onToggleTableOfContents?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: () => void
|
||||
@@ -60,6 +69,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,
|
||||
@@ -113,7 +123,7 @@ function IconActionButton({
|
||||
style,
|
||||
children,
|
||||
testId,
|
||||
tooltipAlign,
|
||||
tooltipAlign = 'end',
|
||||
}: {
|
||||
copy: ActionTooltipCopy
|
||||
onClick?: () => void
|
||||
@@ -344,6 +354,24 @@ function AIChatAction({ showAIChat, locale = 'en', onToggleAIChat }: Pick<Breadc
|
||||
)
|
||||
}
|
||||
|
||||
function TableOfContentsAction({
|
||||
showTableOfContents,
|
||||
locale = 'en',
|
||||
onToggleTableOfContents,
|
||||
}: Pick<BreadcrumbBarProps, 'showTableOfContents' | 'locale' | 'onToggleTableOfContents'>) {
|
||||
if (!onToggleTableOfContents) return null
|
||||
|
||||
return (
|
||||
<IconActionButton
|
||||
copy={{ label: translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents') }}
|
||||
onClick={onToggleTableOfContents}
|
||||
className={cn(showTableOfContents ? 'text-foreground' : 'hover:text-foreground')}
|
||||
>
|
||||
<ListBullets size={16} weight={showTableOfContents ? 'bold' : 'regular'} className={BREADCRUMB_ICON_CLASS} />
|
||||
</IconActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
function ArchiveAction({
|
||||
archived,
|
||||
locale = 'en',
|
||||
@@ -428,11 +456,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 +656,10 @@ function FilenameInput({
|
||||
}
|
||||
|
||||
function FilenameTrigger({
|
||||
entry,
|
||||
filenameStem,
|
||||
locale = 'en',
|
||||
onStartEditing,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
filenameStem: string
|
||||
locale?: AppLocale
|
||||
onStartEditing: () => void
|
||||
@@ -502,8 +681,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 +732,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>
|
||||
)
|
||||
@@ -639,6 +817,8 @@ function BreadcrumbActions({
|
||||
onToggleNoteWidth,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
showTableOfContents,
|
||||
onToggleTableOfContents,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onToggleFavorite,
|
||||
@@ -648,30 +828,167 @@ 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>
|
||||
<TableOfContentsAction
|
||||
showTableOfContents={showTableOfContents}
|
||||
locale={locale}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
/>
|
||||
</OverflowToolbarAction>
|
||||
<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}
|
||||
showTableOfContents={showTableOfContents}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
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,
|
||||
showTableOfContents,
|
||||
onToggleTableOfContents,
|
||||
onRevealFile,
|
||||
onCopyFilePath,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onDelete,
|
||||
locale = 'en',
|
||||
}: Pick<
|
||||
BreadcrumbBarProps,
|
||||
| 'entry'
|
||||
| 'showDiffToggle'
|
||||
| 'diffMode'
|
||||
| 'diffLoading'
|
||||
| 'onToggleDiff'
|
||||
| 'noteWidth'
|
||||
| 'onToggleNoteWidth'
|
||||
| 'showTableOfContents'
|
||||
| 'onToggleTableOfContents'
|
||||
| '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))
|
||||
const tableOfContentsLabel = translate(locale, showTableOfContents ? 'editor.toolbar.closeTableOfContents' : 'editor.toolbar.openTableOfContents')
|
||||
|
||||
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={!onToggleTableOfContents} onSelect={onToggleTableOfContents}>
|
||||
<ListBullets size={16} />
|
||||
{tableOfContentsLabel}
|
||||
</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 +997,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 +1018,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 +1033,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 +1048,15 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-tauri-drag-region
|
||||
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
|
||||
className="breadcrumb-bar__drag-spacer w-6 shrink-0"
|
||||
/>
|
||||
<BreadcrumbActions
|
||||
actionsRef={actionsRef}
|
||||
entry={entry}
|
||||
locale={locale}
|
||||
overflowCollapsed={overflowCollapsed}
|
||||
{...actionProps}
|
||||
/>
|
||||
<BreadcrumbActions entry={entry} locale={locale} {...actionProps} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -67,4 +67,22 @@ describe('BreadcrumbBar filename visibility', () => {
|
||||
expect(editorCss).toContain('padding: 0;')
|
||||
expect(editorCss).toContain('border-radius: 0;')
|
||||
})
|
||||
|
||||
it('offsets the editor-only breadcrumb title past the macOS traffic lights', () => {
|
||||
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
|
||||
|
||||
expect(editorCss).toContain('.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar')
|
||||
expect(editorCss).toContain('--breadcrumb-bar-left-padding: 90px;')
|
||||
})
|
||||
|
||||
it('moves lower-priority breadcrumb actions into the overflow menu from measured overflow state', () => {
|
||||
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
|
||||
|
||||
expect(editorCss).not.toContain('@container (max-width:')
|
||||
expect(editorCss).toContain(".breadcrumb-bar__actions[data-overflow-collapsed='true']")
|
||||
expect(editorCss).toContain('.breadcrumb-bar__overflowable-action')
|
||||
expect(editorCss).toContain('display: none;')
|
||||
expect(editorCss).toContain('.breadcrumb-bar__overflow-menu')
|
||||
expect(editorCss).toContain('display: flex;')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -399,6 +399,7 @@ function OpenCommandPalette({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-command-palette="true"
|
||||
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { describe, it, expect, vi } from 'vitest'
|
||||
import { CreateViewDialog } from './CreateViewDialog'
|
||||
import type { ViewDefinition } from '../types'
|
||||
|
||||
const DIALOG_TEST_TIMEOUT_MS = 10_000
|
||||
|
||||
describe('CreateViewDialog', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
@@ -26,7 +28,7 @@ describe('CreateViewDialog', () => {
|
||||
render(<CreateViewDialog {...defaultProps} />)
|
||||
expect(screen.getByText('Create View')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create')).toBeInTheDocument()
|
||||
})
|
||||
}, DIALOG_TEST_TIMEOUT_MS)
|
||||
|
||||
it('shows "Edit View" title when editingView is provided', () => {
|
||||
render(<CreateViewDialog {...defaultProps} editingView={makeEditingView()} />)
|
||||
|
||||
@@ -176,6 +176,44 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('\u2014').parentElement).toHaveClass('justify-start', 'text-left')
|
||||
})
|
||||
|
||||
it('keeps present empty properties visible and editable', () => {
|
||||
renderPanel({ frontmatter: { 'start date': '' }, onUpdateProperty })
|
||||
|
||||
expect(screen.getByText('Start date')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('\u2014'))
|
||||
const input = screen.getByDisplayValue('')
|
||||
fireEvent.change(input, { target: { value: '2026-05-03' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('start date', '2026-05-03')
|
||||
})
|
||||
|
||||
it('shows missing type-defined properties as gray editable placeholders', () => {
|
||||
const typeEntry = makeEntry({
|
||||
title: 'Book',
|
||||
isA: 'Type',
|
||||
properties: {
|
||||
'start date': null,
|
||||
},
|
||||
})
|
||||
|
||||
renderPanel({
|
||||
entry: makeEntry({ title: 'Dune', isA: 'Book' }),
|
||||
entries: [typeEntry],
|
||||
frontmatter: {},
|
||||
onUpdateProperty,
|
||||
})
|
||||
|
||||
const placeholder = screen.getByTestId('type-derived-property')
|
||||
expect(within(placeholder).getByText('Start date')).toHaveClass('text-muted-foreground/40')
|
||||
fireEvent.click(placeholder)
|
||||
const input = screen.getByDisplayValue('')
|
||||
fireEvent.change(input, { target: { value: '2026-05-04' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('start date', '2026-05-04')
|
||||
})
|
||||
|
||||
it('hides Owner with wikilink value from Properties panel', () => {
|
||||
renderPanel({ frontmatter: { Owner: '[[person/luca]]' } })
|
||||
// Owner with wikilink goes to RelationshipsPanel, not Properties
|
||||
|
||||
@@ -153,6 +153,83 @@ function SuggestedPropertySlot({ label, displayMode, onAdd }: {
|
||||
)
|
||||
}
|
||||
|
||||
function TypeDerivedPropertySlot({
|
||||
propKey,
|
||||
editingKey,
|
||||
displayMode,
|
||||
autoMode,
|
||||
vaultStatuses,
|
||||
vaultTags,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onSaveList,
|
||||
onUpdate,
|
||||
onDisplayModeChange,
|
||||
locale,
|
||||
}: {
|
||||
propKey: string
|
||||
editingKey: string | null
|
||||
displayMode: PropertyDisplayMode
|
||||
autoMode: PropertyDisplayMode
|
||||
vaultStatuses: string[]
|
||||
vaultTags: string[]
|
||||
onStartEdit: (key: string | null) => void
|
||||
onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void
|
||||
onUpdate?: (key: string, value: FrontmatterValue) => void
|
||||
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
locale: AppLocale
|
||||
}) {
|
||||
if (editingKey === propKey) {
|
||||
return (
|
||||
<PropertyRow
|
||||
propKey={propKey}
|
||||
value=""
|
||||
editingKey={editingKey}
|
||||
displayMode={displayMode}
|
||||
autoMode={autoMode}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTags={vaultTags}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
onUpdate={onUpdate}
|
||||
onDisplayModeChange={onDisplayModeChange}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const PlaceholderIcon = DISPLAY_MODE_ICONS[displayMode]
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME}
|
||||
style={PROPERTY_PANEL_ROW_STYLE}
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
disabled={!onUpdate}
|
||||
data-testid="type-derived-property"
|
||||
>
|
||||
<span className={PROPERTY_PANEL_PLACEHOLDER_LABEL_CLASS_NAME}>
|
||||
<span
|
||||
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
|
||||
data-testid="type-derived-property-icon-slot"
|
||||
>
|
||||
<PlaceholderIcon
|
||||
className="size-3.5 shrink-0 text-muted-foreground/40"
|
||||
data-testid={`type-derived-property-icon-${displayMode}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground/40">{humanizePropertyKey(propKey)}</span>
|
||||
</span>
|
||||
<span className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME}>{'\u2014'}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function getExistingPropertyKeys(propertyEntries: [string, FrontmatterValue][], frontmatter: ParsedFrontmatter): Set<string> {
|
||||
const keys = new Set(propertyEntries.map(([key]) => key.toLowerCase()))
|
||||
for (const key of Object.keys(frontmatter)) keys.add(key.toLowerCase())
|
||||
@@ -229,6 +306,139 @@ function useSuggestedPropertyActions({
|
||||
}
|
||||
}
|
||||
|
||||
function PropertyEntryRows({
|
||||
source,
|
||||
entries,
|
||||
editingKey,
|
||||
displayOverrides,
|
||||
vaultStatuses,
|
||||
vaultTagsByKey,
|
||||
locale,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onSaveList,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDisplayModeChange,
|
||||
}: {
|
||||
source: 'frontmatter' | 'type-derived'
|
||||
entries: [string, FrontmatterValue][]
|
||||
editingKey: string | null
|
||||
displayOverrides: Record<string, PropertyDisplayMode>
|
||||
vaultStatuses: string[]
|
||||
vaultTagsByKey: Record<string, string[]>
|
||||
locale: AppLocale
|
||||
onStartEdit: (key: string | null) => void
|
||||
onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void
|
||||
onUpdate?: (key: string, value: FrontmatterValue) => void
|
||||
onDelete?: (key: string) => void
|
||||
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{entries.map(([key, value]) => (
|
||||
source === 'type-derived' ? (
|
||||
<TypeDerivedPropertySlot
|
||||
key={`type-derived:${key}`}
|
||||
propKey={key}
|
||||
editingKey={editingKey}
|
||||
displayMode={getEffectiveDisplayMode(key, value, displayOverrides)}
|
||||
autoMode={detectPropertyType(key, value)}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTags={vaultTagsByKey[key] ?? []}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
onUpdate={onUpdate}
|
||||
onDisplayModeChange={onDisplayModeChange}
|
||||
locale={locale}
|
||||
/>
|
||||
) : (
|
||||
<PropertyRow
|
||||
key={key} propKey={key} value={value}
|
||||
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTags={vaultTagsByKey[key] ?? []}
|
||||
onStartEdit={onStartEdit} onSave={onSave}
|
||||
onSaveList={onSaveList} onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
onDisplayModeChange={onDisplayModeChange}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingSuggestedPropertyRow({
|
||||
pendingSuggestedKey,
|
||||
editingKey,
|
||||
vaultStatuses,
|
||||
vaultTagsByKey,
|
||||
locale,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onSaveList,
|
||||
onDisplayModeChange,
|
||||
}: {
|
||||
pendingSuggestedKey: string | null
|
||||
editingKey: string | null
|
||||
vaultStatuses: string[]
|
||||
vaultTagsByKey: Record<string, string[]>
|
||||
locale: AppLocale
|
||||
onStartEdit: (key: string | null) => void
|
||||
onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void
|
||||
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
}) {
|
||||
if (!pendingSuggestedKey || editingKey !== pendingSuggestedKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertyRow
|
||||
key={`pending:${pendingSuggestedKey}`}
|
||||
propKey={pendingSuggestedKey}
|
||||
value=""
|
||||
editingKey={editingKey}
|
||||
displayMode={getSuggestedDisplayMode(pendingSuggestedKey)}
|
||||
autoMode={getSuggestedDisplayMode(pendingSuggestedKey)}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTags={vaultTagsByKey[pendingSuggestedKey] ?? []}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onSaveList={onSaveList}
|
||||
onUpdate={undefined}
|
||||
onDelete={undefined}
|
||||
onDisplayModeChange={onDisplayModeChange}
|
||||
locale={locale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestedPropertyRows({
|
||||
properties,
|
||||
onAdd,
|
||||
}: {
|
||||
properties: Array<{ key: string; label: string }>
|
||||
onAdd: (key: string) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{properties.map(({ key, label }) => (
|
||||
<SuggestedPropertySlot
|
||||
key={key}
|
||||
label={label}
|
||||
displayMode={getSuggestedDisplayMode(key)}
|
||||
onAdd={() => onAdd(key)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function DynamicPropertiesPanel({
|
||||
entry, frontmatter, entries,
|
||||
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate, onCreateMissingType,
|
||||
@@ -248,12 +458,15 @@ export function DynamicPropertiesPanel({
|
||||
const {
|
||||
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
|
||||
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
|
||||
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
typeDerivedPropertyEntries, handleSaveValue, handleSaveTypeDerivedValue, handleSaveList, handleAdd, handleDisplayModeChange,
|
||||
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
|
||||
const [pendingSuggestedKey, setPendingSuggestedKey] = useState<string | null>(null)
|
||||
const missingTypeName = useMemo(() => resolveMissingTypeName(entry.isA, availableTypes), [entry.isA, availableTypes])
|
||||
|
||||
const existingKeys = useMemo(() => getExistingPropertyKeys(propertyEntries, frontmatter), [propertyEntries, frontmatter])
|
||||
const existingKeys = useMemo(
|
||||
() => getExistingPropertyKeys([...propertyEntries, ...typeDerivedPropertyEntries], frontmatter),
|
||||
[propertyEntries, typeDerivedPropertyEntries, frontmatter],
|
||||
)
|
||||
const missingSuggested = useMemo(
|
||||
() => getMissingSuggestedProperties(Boolean(onAddProperty), existingKeys, pendingSuggestedKey),
|
||||
[existingKeys, onAddProperty, pendingSuggestedKey],
|
||||
@@ -285,46 +498,47 @@ export function DynamicPropertiesPanel({
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
locale={locale}
|
||||
/>
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<PropertyRow
|
||||
key={key} propKey={key} value={value}
|
||||
editingKey={editingKey} displayMode={getEffectiveDisplayMode(key, value, displayOverrides)} autoMode={detectPropertyType(key, value)}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTags={vaultTagsByKey[key] ?? []}
|
||||
onStartEdit={setEditingKey} onSave={handleSaveValue}
|
||||
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
|
||||
onDelete={onDeleteProperty}
|
||||
onDisplayModeChange={handleDisplayModeChange}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
{pendingSuggestedKey && editingKey === pendingSuggestedKey && (
|
||||
<PropertyRow
|
||||
key={`pending:${pendingSuggestedKey}`}
|
||||
propKey={pendingSuggestedKey}
|
||||
value=""
|
||||
editingKey={editingKey}
|
||||
displayMode={getSuggestedDisplayMode(pendingSuggestedKey)}
|
||||
autoMode={getSuggestedDisplayMode(pendingSuggestedKey)}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTags={vaultTagsByKey[pendingSuggestedKey] ?? []}
|
||||
onStartEdit={handlePendingSuggestedEdit}
|
||||
onSave={handleSaveSuggestedValue}
|
||||
onSaveList={handleSaveList}
|
||||
onUpdate={undefined}
|
||||
onDelete={undefined}
|
||||
onDisplayModeChange={handleDisplayModeChange}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
{missingSuggested.map(({ key, label }) => (
|
||||
<SuggestedPropertySlot
|
||||
key={key}
|
||||
label={label}
|
||||
displayMode={getSuggestedDisplayMode(key)}
|
||||
onAdd={() => handleSuggestedAdd(key)}
|
||||
/>
|
||||
))}
|
||||
<PropertyEntryRows
|
||||
source="frontmatter"
|
||||
entries={propertyEntries}
|
||||
editingKey={editingKey}
|
||||
displayOverrides={displayOverrides}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTagsByKey={vaultTagsByKey}
|
||||
locale={locale}
|
||||
onStartEdit={setEditingKey}
|
||||
onSave={handleSaveValue}
|
||||
onSaveList={handleSaveList}
|
||||
onUpdate={onUpdateProperty}
|
||||
onDelete={onDeleteProperty}
|
||||
onDisplayModeChange={handleDisplayModeChange}
|
||||
/>
|
||||
<PropertyEntryRows
|
||||
source="type-derived"
|
||||
entries={typeDerivedPropertyEntries}
|
||||
editingKey={editingKey}
|
||||
displayOverrides={displayOverrides}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTagsByKey={vaultTagsByKey}
|
||||
locale={locale}
|
||||
onStartEdit={setEditingKey}
|
||||
onSave={handleSaveTypeDerivedValue}
|
||||
onSaveList={handleSaveList}
|
||||
onUpdate={onUpdateProperty}
|
||||
onDisplayModeChange={handleDisplayModeChange}
|
||||
/>
|
||||
<PendingSuggestedPropertyRow
|
||||
pendingSuggestedKey={pendingSuggestedKey}
|
||||
editingKey={editingKey}
|
||||
vaultStatuses={vaultStatuses}
|
||||
vaultTagsByKey={vaultTagsByKey}
|
||||
locale={locale}
|
||||
onStartEdit={handlePendingSuggestedEdit}
|
||||
onSave={handleSaveSuggestedValue}
|
||||
onSaveList={handleSaveList}
|
||||
onDisplayModeChange={handleDisplayModeChange}
|
||||
/>
|
||||
<SuggestedPropertyRows properties={missingSuggested} onAdd={handleSuggestedAdd} />
|
||||
{!showAddDialog && (
|
||||
<AddPropertyButton
|
||||
locale={locale}
|
||||
|
||||
@@ -25,9 +25,15 @@
|
||||
/* Breadcrumb bar: border can still react to the data attribute, but the
|
||||
breadcrumb filename/title stays visible at all times. */
|
||||
.breadcrumb-bar {
|
||||
container-type: inline-size;
|
||||
--breadcrumb-bar-left-padding: 16px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.app:not(:has(.app__sidebar)):not(:has(.app__note-list)) .breadcrumb-bar {
|
||||
--breadcrumb-bar-left-padding: 90px;
|
||||
}
|
||||
|
||||
.breadcrumb-bar[data-title-hidden] {
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
@@ -48,6 +54,18 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.breadcrumb-bar__overflow-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breadcrumb-bar__actions[data-overflow-collapsed='true'] .breadcrumb-bar__overflowable-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breadcrumb-bar__actions[data-overflow-collapsed='true'] .breadcrumb-bar__overflow-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Scroll area wrapping title + editor — single scroll context for alignment */
|
||||
.editor-scroll-area {
|
||||
flex: 1;
|
||||
@@ -104,16 +122,16 @@
|
||||
matches our vault theme instead of BlockNote's hardcoded light/dark defaults. */
|
||||
--bn-colors-editor-background: var(--bg-primary);
|
||||
--bn-colors-editor-text: var(--text-primary);
|
||||
--bn-colors-menu-background: var(--bg-card);
|
||||
--bn-colors-menu-background: var(--surface-popover);
|
||||
--bn-colors-menu-text: var(--text-primary);
|
||||
--bn-colors-tooltip-background: var(--bg-hover);
|
||||
--bn-colors-tooltip-background: var(--state-hover-subtle);
|
||||
--bn-colors-tooltip-text: var(--text-primary);
|
||||
--bn-colors-hovered-background: var(--bg-hover);
|
||||
--bn-colors-hovered-background: var(--state-hover);
|
||||
--bn-colors-hovered-text: var(--text-primary);
|
||||
--bn-colors-selected-background: var(--bg-selected);
|
||||
--bn-colors-selected-text: var(--text-primary);
|
||||
--bn-colors-border: var(--border-primary);
|
||||
--bn-colors-shadow: var(--border-primary);
|
||||
--bn-colors-border: var(--border-dialog);
|
||||
--bn-colors-shadow: var(--shadow-dialog);
|
||||
--bn-colors-side-menu: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -122,6 +140,84 @@
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-suggestion-menu {
|
||||
border: 1px solid var(--border-dialog) !important;
|
||||
background: var(--surface-popover) !important;
|
||||
box-shadow: 0 8px 32px var(--shadow-dialog) !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-suggestion-menu-label {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-suggestion-menu-item {
|
||||
color: var(--text-primary) !important;
|
||||
height: 34px !important;
|
||||
min-height: 34px !important;
|
||||
padding: 3px 8px !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="left"] {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
margin-inline-end: 8px !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
border-radius: 0 !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="right"] {
|
||||
margin-inline-start: 12px !important;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="right"] .mantine-Badge-root {
|
||||
min-width: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 10px !important;
|
||||
font-weight: 500 !important;
|
||||
line-height: 1.2 !important;
|
||||
text-transform: none !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-mt-suggestion-menu-item-body {
|
||||
justify-content: center !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-mt-suggestion-menu-item-subtitle {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tolaria-slash-menu-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .tolaria-slash-menu-icon__fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-suggestion-menu-item:is(:hover, [aria-selected="true"]) .tolaria-slash-menu-icon__regular {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-suggestion-menu-item:is(:hover, [aria-selected="true"]) .tolaria-slash-menu-icon__fill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
Bear-style live preview: zero horizontal shift
|
||||
============================================= */
|
||||
|
||||
@@ -153,6 +153,7 @@ import {
|
||||
import type { VaultEntry } from '../types'
|
||||
import { bindVaultConfigStore, resetVaultConfigStore } from '../utils/vaultConfigStore'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { clearParsedNoteBlockCache } from '../hooks/editorParsedBlockCache'
|
||||
|
||||
type EditorComponentProps = ComponentProps<typeof Editor>
|
||||
|
||||
@@ -235,6 +236,8 @@ describe('Editor', () => {
|
||||
beforeEach(() => {
|
||||
blockNoteCreation.options = []
|
||||
blockNoteViewState.onChange = null
|
||||
mockEditor.document = [{ id: '1', type: 'paragraph', content: [], props: {}, children: [] }]
|
||||
clearParsedNoteBlockCache()
|
||||
})
|
||||
|
||||
it('shows empty state when no tabs are open', () => {
|
||||
@@ -535,6 +538,27 @@ describe('Editor', () => {
|
||||
expect(screen.getAllByText('Properties').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders the table of contents panel from the active note content', async () => {
|
||||
mockEditor.document = [
|
||||
{ id: 'toc-heading', type: 'heading', content: [{ type: 'text', text: 'Table Heading' }], props: { level: 2 }, children: [] },
|
||||
]
|
||||
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
inspectorEntry={mockEntry}
|
||||
inspectorContent={`${mockContent}\n\n## Table Heading`}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open table of contents' }))
|
||||
|
||||
expect(screen.getByTestId('table-of-contents-panel')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'Table Heading' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Regression: editor content did not appear on first load because BlockNote's
|
||||
// replaceBlocks/insertBlocks internally calls flushSync, which fails silently
|
||||
// when invoked from within React's useEffect. Fix: defer via queueMicrotask.
|
||||
@@ -722,6 +746,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', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import '@blocknote/mantine/style.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import { uploadImageFile } from '../hooks/useImageDrop'
|
||||
import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents'
|
||||
import type { AiTarget } from '../lib/aiTargets'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
|
||||
import type { VaultEntry, GitCommit, NoteWidthMode, NoteStatus } from '../types'
|
||||
@@ -17,8 +18,10 @@ 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 { useRightPanelExclusion } from './useRightPanelExclusion'
|
||||
import type { RawEditorFindRequest } from './RawEditorFindBar'
|
||||
import {
|
||||
applyPendingRawExitContent,
|
||||
@@ -55,6 +58,7 @@ interface EditorProps {
|
||||
onToggleInspector: () => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent?: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady?: boolean
|
||||
onInspectorResize: (delta: number) => void
|
||||
@@ -188,6 +192,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 +216,7 @@ function useEditorSetup({
|
||||
activeTab?.content ?? null,
|
||||
onContentChange,
|
||||
vaultPath,
|
||||
flushPendingEditorChangeRef,
|
||||
)
|
||||
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
|
||||
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
|
||||
@@ -226,6 +232,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({
|
||||
@@ -310,6 +324,8 @@ function EditorLayout({
|
||||
showDiffToggle,
|
||||
showAIChat,
|
||||
onToggleAIChat,
|
||||
showTableOfContents,
|
||||
onToggleTableOfContents,
|
||||
inspectorCollapsed,
|
||||
onToggleInspector,
|
||||
onNavigateWikilink,
|
||||
@@ -335,6 +351,7 @@ function EditorLayout({
|
||||
onInspectorResize,
|
||||
inspectorWidth,
|
||||
defaultAiAgent,
|
||||
defaultAiTarget,
|
||||
defaultAiAgentReadiness,
|
||||
defaultAiAgentReady,
|
||||
inspectorEntry,
|
||||
@@ -374,6 +391,8 @@ function EditorLayout({
|
||||
showDiffToggle: boolean
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
showTableOfContents?: boolean
|
||||
onToggleTableOfContents?: () => void
|
||||
inspectorCollapsed: boolean
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
@@ -399,6 +418,7 @@ function EditorLayout({
|
||||
onInspectorResize: (delta: number) => void
|
||||
inspectorWidth: number
|
||||
defaultAiAgent: AiAgentId
|
||||
defaultAiTarget?: AiTarget
|
||||
defaultAiAgentReadiness?: AiAgentReadiness
|
||||
defaultAiAgentReady: boolean
|
||||
inspectorEntry: VaultEntry | null
|
||||
@@ -455,6 +475,8 @@ function EditorLayout({
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
showTableOfContents={showTableOfContents}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
@@ -479,12 +501,15 @@ function EditorLayout({
|
||||
locale={locale}
|
||||
/>
|
||||
}
|
||||
{(showAIChat || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
{(showAIChat || showTableOfContents || !inspectorCollapsed) && <ResizeHandle onResize={onInspectorResize} />}
|
||||
<EditorRightPanel
|
||||
showAIChat={showAIChat}
|
||||
showTableOfContents={showTableOfContents}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
inspectorWidth={inspectorWidth}
|
||||
editor={editor}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiTarget={defaultAiTarget}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
@@ -497,6 +522,7 @@ function EditorLayout({
|
||||
noteListFilter={noteListFilter}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
onToggleTableOfContents={onToggleTableOfContents}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
onViewCommitDiff={handleViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
@@ -513,129 +539,68 @@ function EditorLayout({
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
<EditorMemoryProbe entries={entries} vaultPath={vaultPath} locale={locale} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const {
|
||||
tabs, activeTabPath, entries, onNavigateWikilink,
|
||||
isVaultLoading,
|
||||
getNoteStatus,
|
||||
inspectorCollapsed, onToggleInspector, inspectorWidth,
|
||||
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReadiness, defaultAiAgentReady = true,
|
||||
onUnsupportedAiPaste,
|
||||
onInspectorResize,
|
||||
inspectorEntry, inspectorContent, gitHistory,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
|
||||
onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onRenameFilename,
|
||||
noteWidth, onToggleNoteWidth,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
isConflicted, onKeepMine, onKeepTheirs,
|
||||
flushPendingEditorContentRef, flushPendingRawContentRef, findInNoteRef, locale,
|
||||
} = props
|
||||
type EditorRuntime = ReturnType<typeof useEditorSetup>
|
||||
type EditorLayoutProps = Parameters<typeof EditorLayout>[0]
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, flushPendingEditorChange, handleViewCommitDiff,
|
||||
isLoadingNewTab, activeStatus, showDiffToggle,
|
||||
} = useEditorSetup({
|
||||
tabs, activeTabPath, vaultPath, onContentChange,
|
||||
function buildEditorLayoutProps(
|
||||
props: EditorProps,
|
||||
runtime: EditorRuntime,
|
||||
findRequest: RawEditorFindRequest | null,
|
||||
): EditorLayoutProps {
|
||||
return {
|
||||
...props,
|
||||
...runtime,
|
||||
activeTabPath: props.activeTabPath,
|
||||
defaultAiAgent: props.defaultAiAgent ?? DEFAULT_AI_AGENT,
|
||||
defaultAiAgentReady: props.defaultAiAgentReady ?? true,
|
||||
findRequest,
|
||||
}
|
||||
}
|
||||
|
||||
export const Editor = memo(function Editor(props: EditorProps) {
|
||||
const runtime = useEditorSetup({
|
||||
tabs: props.tabs,
|
||||
activeTabPath: props.activeTabPath,
|
||||
vaultPath: props.vaultPath,
|
||||
onContentChange: props.onContentChange,
|
||||
onLoadDiff: props.onLoadDiff,
|
||||
onLoadDiffAtCommit: props.onLoadDiffAtCommit,
|
||||
pendingCommitDiffRequest: props.pendingCommitDiffRequest,
|
||||
onPendingCommitDiffHandled: props.onPendingCommitDiffHandled,
|
||||
getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef, diffToggleRef: props.diffToggleRef,
|
||||
getNoteStatus: props.getNoteStatus,
|
||||
rawToggleRef: props.rawToggleRef,
|
||||
diffToggleRef: props.diffToggleRef,
|
||||
})
|
||||
const findRequest = useEditorFindCommand({
|
||||
activeTab,
|
||||
findInNoteRef,
|
||||
handleToggleRawExclusive,
|
||||
rawMode,
|
||||
activeTab: runtime.activeTab,
|
||||
findInNoteRef: props.findInNoteRef,
|
||||
handleToggleRawExclusive: runtime.handleToggleRawExclusive,
|
||||
rawMode: runtime.rawMode,
|
||||
})
|
||||
|
||||
useRegisterEditorContentFlushes({
|
||||
activeTab,
|
||||
flushPendingEditorChange,
|
||||
flushPendingEditorContentRef,
|
||||
rawLatestContentRef,
|
||||
rawMode,
|
||||
onContentChange,
|
||||
flushPendingRawContentRef,
|
||||
activeTab: runtime.activeTab,
|
||||
flushPendingEditorChange: runtime.flushPendingEditorChange,
|
||||
flushPendingEditorContentRef: props.flushPendingEditorContentRef,
|
||||
rawLatestContentRef: runtime.rawLatestContentRef,
|
||||
rawMode: runtime.rawMode,
|
||||
onContentChange: props.onContentChange,
|
||||
flushPendingRawContentRef: props.flushPendingRawContentRef,
|
||||
})
|
||||
const rightPanel = useRightPanelExclusion(props)
|
||||
|
||||
return (
|
||||
<EditorLayout
|
||||
tabs={tabs}
|
||||
activeTabPath={props.activeTabPath}
|
||||
activeTab={activeTab}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
isVaultLoading={isVaultLoading}
|
||||
entries={entries}
|
||||
editor={editor}
|
||||
diffMode={diffMode}
|
||||
diffContent={diffContent}
|
||||
diffLoading={diffLoading}
|
||||
handleToggleDiffExclusive={handleToggleDiffExclusive}
|
||||
rawMode={rawMode}
|
||||
handleToggleRawExclusive={handleToggleRawExclusive}
|
||||
onContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
activeStatus={activeStatus}
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
handleEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onToggleOrganized={onToggleOrganized}
|
||||
onRevealFile={onRevealFile}
|
||||
onCopyFilePath={onCopyFilePath}
|
||||
onOpenExternalFile={onOpenExternalFile}
|
||||
onDeleteNote={onDeleteNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
findRequest={findRequest}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
noteWidth={noteWidth}
|
||||
onToggleNoteWidth={onToggleNoteWidth}
|
||||
isConflicted={isConflicted}
|
||||
onKeepMine={onKeepMine}
|
||||
onKeepTheirs={onKeepTheirs}
|
||||
onInspectorResize={onInspectorResize}
|
||||
inspectorWidth={inspectorWidth}
|
||||
defaultAiAgent={defaultAiAgent}
|
||||
defaultAiAgentReadiness={defaultAiAgentReadiness}
|
||||
defaultAiAgentReady={defaultAiAgentReady}
|
||||
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
gitHistory={gitHistory}
|
||||
noteList={noteList}
|
||||
noteListFilter={noteListFilter}
|
||||
handleViewCommitDiff={handleViewCommitDiff}
|
||||
onUpdateFrontmatter={onUpdateFrontmatter}
|
||||
onDeleteProperty={onDeleteProperty}
|
||||
onAddProperty={onAddProperty}
|
||||
onCreateMissingType={onCreateMissingType}
|
||||
onCreateAndOpenNote={onCreateAndOpenNote}
|
||||
onInitializeProperties={onInitializeProperties}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
locale={locale}
|
||||
{...buildEditorLayoutProps(props, runtime, findRequest)}
|
||||
onToggleInspector={rightPanel.handleToggleInspectorPanel}
|
||||
showAIChat={props.showAIChat}
|
||||
onToggleAIChat={rightPanel.handleToggleAIChatPanel}
|
||||
showTableOfContents={rightPanel.showTableOfContents}
|
||||
onToggleTableOfContents={rightPanel.handleToggleTableOfContents}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
82
src/components/EditorMemoryProbe.tsx
Normal file
82
src/components/EditorMemoryProbe.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { HiddenEditorMemoryProbe } from './HiddenEditorMemoryProbe'
|
||||
import type { EditorMemoryProbeApi } from './editorMemoryProbeTypes'
|
||||
import { useEditorMemoryProbeController } from './useEditorMemoryProbeController'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__tolariaEditorMemoryProbe?: EditorMemoryProbeApi
|
||||
}
|
||||
}
|
||||
|
||||
function useEditorMemoryProbeBridge(api: EditorMemoryProbeApi): void {
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return
|
||||
window.__tolariaEditorMemoryProbe = api
|
||||
return () => {
|
||||
if (window.__tolariaEditorMemoryProbe?.run === api.run) {
|
||||
delete window.__tolariaEditorMemoryProbe
|
||||
}
|
||||
}
|
||||
}, [api])
|
||||
}
|
||||
|
||||
function useEditorMemoryProbeShortcut(runAndCopy: EditorMemoryProbeApi['runAndCopy']): void {
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!event.metaKey || !event.altKey || !event.shiftKey || event.code !== 'KeyM') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void runAndCopy()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
}, [runAndCopy])
|
||||
}
|
||||
|
||||
export function EditorMemoryProbe({
|
||||
entries,
|
||||
locale,
|
||||
vaultPath,
|
||||
}: {
|
||||
entries: VaultEntry[]
|
||||
locale?: AppLocale
|
||||
vaultPath?: string
|
||||
}) {
|
||||
const controller = useEditorMemoryProbeController(entries)
|
||||
useEditorMemoryProbeBridge(controller)
|
||||
useEditorMemoryProbeShortcut(controller.runAndCopy)
|
||||
|
||||
if (!import.meta.env.DEV || controller.targets.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
height: 1,
|
||||
left: -100_000,
|
||||
opacity: 0,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
width: 1,
|
||||
zIndex: -1,
|
||||
}}
|
||||
>
|
||||
{controller.targets.map(target => (
|
||||
<HiddenEditorMemoryProbe
|
||||
key={target.entry.path}
|
||||
entries={entries}
|
||||
locale={locale}
|
||||
onReady={controller.handleReady}
|
||||
target={target}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user