Compare commits

..

134 Commits

Author SHA1 Message Date
lucaronin
bf3caab862 fix: recover blocked native folder picker 2026-06-07 15:00:51 +02:00
lucaronin
2556907f36 fix: launch Pi CLI from Windows shims 2026-06-07 12:04:55 +02:00
lucaronin
8e3f8fbb77 fix: bound ai agent startup status probe 2026-06-07 05:46:41 +02:00
lucaronin
501df0e265 refactor: share rich editor input transforms 2026-06-07 03:25:09 +02:00
lucaronin
48eac1e40d fix: recover paragraph editor index errors 2026-06-07 02:49:04 +02:00
lucaronin
3d28e21a5a fix: keep palette keyboard selection stable 2026-06-06 14:49:43 +02:00
lucaronin
191066a3bf feat: copy note git URLs 2026-06-06 14:24:51 +02:00
lucaronin
3a88724d8f test: update stable signing bypass guard 2026-06-06 12:06:17 +02:00
lucaronin
65ea3f37fc ci: allow v2026-06-06 stable without windows signing 2026-06-06 11:42:57 +02:00
lucaronin
0e81f20e31 docs: add release notes for v2026-06-06 2026-06-06 11:32:51 +02:00
lucaronin
3c8edd8346 fix: let OpenAI API create notes 2026-06-06 10:42:33 +02:00
lucaronin
80ee94da73 fix: localize highlight toolbar copy 2026-06-06 10:35:21 +02:00
lucaronin
008e8f3471 fix: support rich-editor markdown highlights 2026-06-06 10:15:44 +02:00
lucaronin
6aaaca8e24 fix: sync mcp server lockfile 2026-06-06 09:31:38 +02:00
lucaronin
c5dc4a9300 fix: handle symlinked gitignored vault paths 2026-06-06 08:21:49 +02:00
lucaronin
25c7ba0926 test: cover stack-based null append recovery 2026-06-06 04:24:52 +02:00
lucaronin
2b1f4395b3 fix: recover null editor transform errors 2026-06-06 03:57:21 +02:00
lucaronin
e3ef3f6073 test: stabilize wikilink ime composition test 2026-06-06 03:50:51 +02:00
lucaronin
8d47b8d7d2 fix: correct Chinese file manager labels 2026-06-06 03:45:14 +02:00
lucaronin
8fc366c8c0 fix: scope math block selection highlight 2026-06-06 03:23:04 +02:00
lucaronin
42d7a3ac44 refactor: share mcp tool orchestration 2026-06-06 02:27:19 +02:00
lucaronin
3e349fa017 fix: render markdown highlight syntax 2026-06-06 01:34:06 +02:00
lucaronin
8138925bb0 fix: localize custom menus and feedback 2026-06-06 00:48:10 +02:00
lucaronin
cc4203ab1c fix: expose mermaid raw edit action 2026-06-05 23:32:01 +02:00
lucaronin
401087a8ef fix: recover plain table row index errors 2026-06-05 22:58:28 +02:00
lucaronin
0053a36b6e fix: guard title focus without block id 2026-06-05 22:36:26 +02:00
lucaronin
762ac4ff48 fix: guard native drag drop payloads 2026-06-05 22:11:49 +02:00
lucaronin
4716003ca1 fix: refresh sync history surfaces 2026-06-05 21:43:53 +02:00
lucaronin
af9cc118e7 fix: inherit shell ai provider env 2026-06-05 20:44:28 +02:00
lucaronin
95bdbc90c7 feat: add whiteboard fullscreen workspace 2026-06-05 20:01:55 +02:00
lucaronin
12a229e604 fix: align inline math with text baseline 2026-06-05 19:21:22 +02:00
lucaronin
cc52a52216 fix: recognize raw editor backslash shortcut 2026-06-05 18:49:14 +02:00
lucaronin
057c73376c fix: preserve rich editor copy html 2026-06-05 18:16:16 +02:00
lucaronin
a194763847 fix: restore editor focus after active refresh 2026-06-05 17:20:43 +02:00
lucaronin
6945de0060 fix: tolerate stale Pi agent config entries 2026-06-05 16:45:59 +02:00
lucaronin
4e81ab8aa3 fix: add MCP create note tool 2026-06-05 16:27:18 +02:00
lucaronin
1309cf322e fix: respect configured Git author identity 2026-06-05 15:55:28 +02:00
lucaronin
c9467711ad fix: keep view filters focused across workspaces 2026-06-05 15:22:13 +02:00
lucaronin
82b2ff2ac4 ci: allow unsigned Windows stable build for v2026-06-01 2026-06-01 16:25:07 +02:00
lucaronin
34a22c485d docs: add release notes for v2026-06-01 2026-06-01 16:13:26 +02:00
Tolaria
c12aa4f4fd fix: keep math source selection readable 2026-06-01 15:42:44 +02:00
Luca Rossi
6c979addb3 Delete docs/LARGE-VAULT-LOADING-QA.md 2026-06-01 12:45:46 +02:00
Tolaria
7fa284f0d0 fix: edit display math as block property 2026-06-01 10:05:11 +02:00
Tolaria
74891e0283 fix: export notes directly to PDF 2026-05-31 15:11:29 +02:00
lucaronin
f003b38518 fix: avoid Windows npm cmd shim launches 2026-05-31 12:13:18 +02:00
lucaronin
b84d6579da fix: refresh clean active notes after external edits 2026-05-30 23:04:02 +02:00
lucaronin
4f52f3e803 refactor: reduce view filter test duplication 2026-05-30 21:39:09 +02:00
lucaronin
83cd7bc20d refactor: improve ai workspace code health 2026-05-30 21:10:41 +02:00
lucaronin
0cdf758f7d fix: localize ai workspace copy 2026-05-30 04:19:51 +02:00
lucaronin
cba038766c refactor: share ai workspace cross-window store 2026-05-30 03:49:40 +02:00
lucaronin
944ab9705a refactor: extract app shell side effects 2026-05-30 03:23:29 +02:00
lucaronin
1a08102188 refactor: extract app orchestration hooks 2026-05-30 02:43:28 +02:00
lucaronin
1633f10752 fix: swallow stale tauri listener cleanup 2026-05-29 21:12:23 +02:00
lucaronin
c09f1b9c93 test: stabilize ai workspace title persistence 2026-05-29 20:46:35 +02:00
lucaronin
8cee321ebe feat: add common code block grammars 2026-05-29 20:35:53 +02:00
lucaronin
f79cdac570 fix: require signing for stable windows releases 2026-05-29 19:43:48 +02:00
lucaronin
10aed871da feat: export notes as pdf 2026-05-29 19:37:38 +02:00
lucaronin
30cde097d2 ci: allow unsigned Windows stable build 2026-05-29 19:04:15 +02:00
lucaronin
d4e66443bd docs: add release notes for v2026-05-29 2026-05-29 18:36:36 +02:00
lucaronin
1d5d573138 feat: refine AI workspace side panel 2026-05-29 18:00:32 +02:00
lucaronin
ed8a0b155b fix: recover block type render mismatch 2026-05-29 17:44:24 +02:00
lucaronin
8b937012f9 fix: restore editable math source 2026-05-29 17:16:26 +02:00
lucaronin
6a80d01c34 fix: create unmatched quick open notes safely 2026-05-29 16:34:05 +02:00
lucaronin
ef55891cc2 fix: preserve katex square root radicals 2026-05-29 11:50:09 +02:00
lucaronin
9a2c025d07 fix: recover table row render errors 2026-05-29 11:20:45 +02:00
lucaronin
9be7049bfd docs: streamline agent instructions 2026-05-29 11:02:13 +02:00
Luca Rossi
ff569bfba4 updated README 2026-05-29 10:54:15 +02:00
lucaronin
34fda19b62 docs: add ADR for request-scoped AI stream events (guard — from commit 78892c4) 2026-05-29 08:01:49 +02:00
lucaronin
b69cf8dd5b refactor: consolidate git remote helpers 2026-05-29 05:18:52 +02:00
lucaronin
0234314945 test: guard note-list body search against stale results 2026-05-29 04:06:09 +02:00
lucaronin
6bf70eb635 refactor: consolidate note-list body search 2026-05-29 03:19:59 +02:00
lucaronin
78892c4637 feat: add side AI workspace 2026-05-29 00:58:40 +02:00
lucaronin
6910347bf3 fix: preserve edited vault guidance status 2026-05-28 22:00:29 +02:00
lucaronin
181ff6de8c fix: search note list body content 2026-05-28 21:19:55 +02:00
lucaronin
07c47e68f9 fix: preserve spaced filenames in pending changes 2026-05-28 20:02:21 +02:00
lucaronin
05a6e61f11 fix: extend favorite row hover state 2026-05-28 15:56:38 +02:00
lucaronin
7de2726f27 fix: resolve packaged macos mcp server path 2026-05-28 11:18:11 +02:00
lucaronin
a45e44d0f3 test: align release workflow assertions 2026-05-28 10:30:42 +02:00
lucaronin
14b1668ebe fix: unblock alpha release without windows cert 2026-05-28 10:18:45 +02:00
lucaronin
c5049e4332 docs: add ADR for reusable release artifact workflow (guard - from commit c6e570d) 2026-05-28 08:01:42 +02:00
lucaronin
c6e570d2e3 refactor: deduplicate release artifact workflows 2026-05-28 01:51:08 +02:00
lucaronin
e93676bbb7 test: format git no remote regression 2026-05-28 00:59:55 +02:00
lucaronin
952e632622 test: stabilize add remote smoke 2026-05-28 00:53:25 +02:00
lucaronin
641dc2c885 fix: merge local-only git remote PR #739 2026-05-28 00:42:44 +02:00
lucaronin
78f8469c98 perf: merge AI agent startup discovery PR #751 2026-05-27 23:59:59 +02:00
lucaronin
1ec5f7c745 fix: complete Indonesian locale keys 2026-05-27 23:20:15 +02:00
lucaronin
f616fe449b feat: merge Indonesian translation PR #757 2026-05-27 23:14:50 +02:00
lucaronin
8ad23f72e1 fix: recover table-cell transform errors 2026-05-27 19:50:16 +02:00
lucaronin
41b531e899 fix: avoid inactive AI chat context work 2026-05-27 19:23:06 +02:00
lucaronin
03a667661e fix: guard whiteboard permission rejections 2026-05-27 18:56:36 +02:00
lucaronin
908994c253 fix: repair malformed nested editor blocks 2026-05-27 18:32:18 +02:00
lucaronin
af83d270fd fix: coalesce overlapping editor saves 2026-05-27 17:37:16 +02:00
lucaronin
24f4ee3272 fix: harden tldraw text measurement guard 2026-05-27 16:42:12 +02:00
lucaronin
bbb30d6b91 refactor: consolidate anchored dropdown positioning 2026-05-27 16:08:24 +02:00
lucaronin
ba3f324a52 fix: sign Windows release installers 2026-05-27 15:27:34 +02:00
github-actions[bot]
5240aa3b4e Merge branch 'main' into feat/add-indonesia-locale 2026-05-27 13:04:29 +00:00
lucaronin
f80e571c78 feat: add vault item deep links 2026-05-27 14:51:32 +02:00
github-actions[bot]
df5c2a46e6 Merge branch 'main' into feat/add-indonesia-locale 2026-05-27 10:53:16 +00:00
github-actions[bot]
0bad22eb57 Merge branch 'main' into feat/add-indonesia-locale 2026-05-27 09:39:26 +00:00
github-actions[bot]
bb5296ba3d Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 23:39:16 +00:00
github-actions[bot]
29bfde6a20 Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 23:18:02 +00:00
github-actions[bot]
5fb1389d69 Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 17:51:07 +00:00
github-actions[bot]
1d93da3ade Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 17:29:26 +00:00
github-actions[bot]
18c6d18474 Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 15:02:15 +00:00
github-actions[bot]
8df1f3043d Merge branch 'main' into pr-726 2026-05-26 13:35:13 +00:00
github-actions[bot]
95b89e331d Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 13:35:09 +00:00
Aris Ripandi
0f5ac35f67 fix: add missing locales keys for Indonesian translation 2026-05-26 17:21:07 +07:00
Aris Ripandi
6424509bb9 Merge branch 'main' into feat/add-indonesia-locale 2026-05-26 17:10:28 +07:00
Aris Ripandi
7614db62f2 fix: increase sidebar width to avoid truncated label 2026-05-26 17:06:15 +07:00
github-actions[bot]
42dc2f726f Merge branch 'main' into pr-726 2026-05-26 10:05:58 +00:00
github-actions[bot]
37536e1974 Merge branch 'main' into pr-726 2026-05-26 09:53:49 +00:00
Aris Ripandi
9234ffcac2 Merge branch 'refactoringhq:main' into feat/add-indonesia-locale 2026-05-26 16:49:37 +07:00
Aris Ripandi
198b7c993f feat: add Indonesia translation 2026-05-26 16:47:45 +07:00
github-actions[bot]
c8eba73863 Merge branch 'main' into pr-726 2026-05-26 08:42:14 +00:00
Elias Stiffel
fe1ff59879 perf: defer and parallelize AI agent CLI discovery at startup
get_ai_agents_status used to probe all six agent CLIs (claude_code,
codex, opencode, pi, gemini, kiro) sequentially during cold start. For
each agent missing from PATH it would fall through to three login-shell
spawns (`<shell> -lc 'command -v <agent>'`), each evaluating the user's
full zsh init. On a machine with no agents installed this added ~5 s to
cold start.

The result is not on the first-paint render path. It feeds the status
bar AI badge, the AI tab in settings, and the AI agents onboarding
prompt — all of which are gated behind aiFeaturesEnabled and the
onboarding flow, so they happily render nothing while the probe is
pending.

Three additive changes:

* useAiAgentsStatus now takes an `enabled` option. When false (AI
  features off, or running in a detached note window) the probe never
  fires. App.tsx passes `aiFeaturesEnabled && !noteWindowParams`.
* When enabled, the invoke call is deferred via requestIdleCallback
  (with setTimeout(0) fallback because WKWebView lacks the API) so it
  lands after first paint instead of during it.
* Rust get_ai_agents_status is now async and fans the six check_cli()
  calls out under tokio::task::spawn_blocking + tokio::join!. Per-agent
  check_cli() signatures stay sync, so run_agent_stream callers are
  unaffected and the per-agent unit tests need no changes.

Cold-start measurements on this machine (no agents on PATH, dev mode):

  Baseline (main, AI on):       cascade settles at t+15.5 s
  Patched, AI features off:                       t+5.0 s
  Patched, AI features on:                        t+6.0 s,
                                  AI status arrives ~1.6 s after first
                                  paint, fully off the critical path.

Fixes: https://github.com/refactoringhq/tolaria/issues/726

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:35:15 +02:00
github-actions[bot]
0ffacb3a83 Merge branch 'main' into pr-734 2026-05-24 10:54:08 +00:00
github-actions[bot]
bb68ba78b6 Merge branch 'main' into pr-734 2026-05-24 10:39:28 +00:00
github-actions[bot]
86e414d192 Merge branch 'main' into pr-734 2026-05-24 10:18:49 +00:00
github-actions[bot]
29774c4f46 Merge branch 'main' into pr-734 2026-05-24 09:01:07 +00:00
github-actions[bot]
7f7272e09b Merge branch 'main' into pr-734 2026-05-24 08:44:19 +00:00
github-actions[bot]
d87d15982f Merge branch 'main' into pr-734 2026-05-24 08:22:23 +00:00
github-actions[bot]
f1c2470a6e Merge branch 'main' into pr-734 2026-05-23 21:49:25 +00:00
github-actions[bot]
2a15ecb3e3 Merge branch 'main' into pr-734 2026-05-23 21:01:30 +00:00
github-actions[bot]
73766ca471 Merge branch 'main' into pr-734 2026-05-23 20:30:09 +00:00
github-actions[bot]
d8e280b9ee Merge branch 'main' into pr-734 2026-05-23 20:08:04 +00:00
github-actions[bot]
2d04335648 Merge branch 'main' into pr-734 2026-05-23 19:46:26 +00:00
github-actions[bot]
c903eb1e79 Merge branch 'main' into pr-734 2026-05-23 19:06:32 +00:00
github-actions[bot]
f2bbcef7f0 Merge branch 'main' into pr-734 2026-05-23 18:17:55 +00:00
github-actions[bot]
2172719f3e Merge branch 'main' into pr-734 2026-05-23 17:26:40 +00:00
github-actions[bot]
5b275629be Merge branch 'main' into pr-734 2026-05-23 16:30:05 +00:00
github-actions[bot]
66b1a2b301 Merge branch 'main' into pr-734 2026-05-23 16:00:34 +00:00
Elias Stiffel
3403c56394 test: align desktop_remote_commands_report_no_remote with has_remote fix
The test's name says "report_no_remote" but its push assertion still
expected the pre-fix behavior ("error"). After 6d97335d made has_remote
URL-aware, git_push on a vault with no URL-bearing remote short-circuits
with "no_remote" — matching the test name and the pull assertion above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:47:58 +02:00
Elias Stiffel
2b368dc8f0 fix(git): list_remotes ignores name-only inherited remote sections
Sibling to the has_remote fix on this branch (6d97335d). Plain `git remote`
lists remote names defined in any config layer, so a [remote "origin"]
section inherited from ~/.gitconfig (e.g. with `prune = true`) made
list_remotes return ["origin"] for fresh local vaults. That broke
git_add_remote (returned "already_configured") and silently neutered
disconnect_all_remotes.

Reuse remote::remote_line_has_url to parse `git remote -v` output and
keep only remotes with a non-empty URL column. Dedupe by name across
the (fetch)/(push) line pair.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:46:54 +02:00
Elias Stiffel
6d97335d68 fix: treat name-only remote sections as no remote
`has_remote()` ran bare `git remote`, which lists every remote name from
any config layer — so a `[remote "origin"]` section inherited from the
user's `~/.gitconfig` (e.g. with only `prune = true`) made every vault
look remote-backed. `git_pull` then ran `git pull --no-rebase`, hit
"no tracking information", and the status bar surfaced "Sync failed".

Parse `git remote -v` and require at least one line with a non-empty URL
column. Mirror the guard on `git_push` so direct callers return
`no_remote` instead of issuing a doomed push. Handle `no_remote`
explicitly in `useAutoSync.performPull` / `pullAndPush` / `handlePushResult`
so the frontend short-circuits the push step and keeps a neutral status.

Fixes: https://github.com/refactoringhq/tolaria/issues/734

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:19:34 +02:00
343 changed files with 22428 additions and 4756 deletions

View File

@@ -0,0 +1,115 @@
param(
[string]$ConfigPath = "src-tauri/tauri.windows-signing.conf.json"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Read-FirstEnv {
param([string[]]$Names)
foreach ($Name in $Names) {
$Value = [Environment]::GetEnvironmentVariable($Name)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value.Trim()
}
}
throw "Set one of these environment variables: $($Names -join ', ')"
}
function Read-OptionalEnv {
param(
[string[]]$Names,
[string]$DefaultValue
)
foreach ($Name in $Names) {
$Value = [Environment]::GetEnvironmentVariable($Name)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value.Trim()
}
}
return $DefaultValue
}
function Normalize-Thumbprint {
param([string]$Thumbprint)
return ($Thumbprint -replace "\s", "").ToUpperInvariant()
}
function Convert-CertificateSecretToBytes {
param([string]$CertificateSecret)
$Base64Lines = $CertificateSecret -split "\r?\n" |
Where-Object { $_ -notmatch "^-+BEGIN " -and $_ -notmatch "^-+END " }
$CertificateBase64 = ($Base64Lines -join "") -replace "\s", ""
try {
return [Convert]::FromBase64String($CertificateBase64)
} catch {
throw "Windows code-signing certificate must be base64-encoded PFX data."
}
}
$CertificateSecret = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE", "WINDOWS_CERTIFICATE")
$CertificatePassword = Read-FirstEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD", "WINDOWS_CERTIFICATE_PASSWORD")
$ConfiguredThumbprint = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT", "WINDOWS_CERTIFICATE_THUMBPRINT") ""
$DigestAlgorithm = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_DIGEST_ALGORITHM") "sha256"
$TimestampUrl = Read-OptionalEnv @("WINDOWS_CODE_SIGNING_TIMESTAMP_URL", "WINDOWS_TIMESTAMP_URL") "http://timestamp.digicert.com"
$TempRoot = Join-Path ([IO.Path]::GetTempPath()) "tolaria-windows-signing"
if (-not [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
$TempRoot = Join-Path $env:RUNNER_TEMP "tolaria-windows-signing"
}
New-Item -ItemType Directory -Force -Path $TempRoot | Out-Null
$PfxPath = Join-Path $TempRoot "certificate.pfx"
[IO.File]::WriteAllBytes($PfxPath, (Convert-CertificateSecretToBytes $CertificateSecret))
$SecurePassword = ConvertTo-SecureString -String $CertificatePassword -Force -AsPlainText
$ImportedCertificates = @(Import-PfxCertificate -FilePath $PfxPath -CertStoreLocation Cert:\CurrentUser\My -Password $SecurePassword)
Remove-Item -Force -ErrorAction SilentlyContinue $PfxPath
$ImportedCertificate = $ImportedCertificates | Where-Object { $_.HasPrivateKey } | Select-Object -First 1
if ($null -eq $ImportedCertificate) {
throw "The imported Windows code-signing certificate does not include a private key."
}
if ([string]::IsNullOrWhiteSpace($ConfiguredThumbprint)) {
$CertificateThumbprint = Normalize-Thumbprint $ImportedCertificate.Thumbprint
} else {
$CertificateThumbprint = Normalize-Thumbprint $ConfiguredThumbprint
}
$StoreCertificate = Get-ChildItem Cert:\CurrentUser\My |
Where-Object { (Normalize-Thumbprint $_.Thumbprint) -eq $CertificateThumbprint } |
Select-Object -First 1
if ($null -eq $StoreCertificate) {
throw "The requested Windows code-signing certificate thumbprint was not found in Cert:\CurrentUser\My."
}
$Config = @{
bundle = @{
windows = @{
certificateThumbprint = $CertificateThumbprint
digestAlgorithm = $DigestAlgorithm
timestampUrl = $TimestampUrl
}
}
}
$ResolvedConfigPath = Resolve-Path -Path (Split-Path -Parent $ConfigPath) -ErrorAction SilentlyContinue
if ($null -eq $ResolvedConfigPath) {
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ConfigPath) | Out-Null
}
$Config | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigPath -Encoding utf8NoBOM
if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) {
"WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT=$CertificateThumbprint" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
Write-Host "Prepared Windows Authenticode signing config at $ConfigPath."

View File

@@ -0,0 +1,567 @@
name: Release build artifacts
on:
workflow_call:
inputs:
version:
required: true
type: string
macos_bundles:
required: false
type: string
default: ""
upload_macos_dmg:
required: true
type: boolean
require_windows_authenticode:
required: false
type: boolean
default: true
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# The production Vite bundle can exceed Node's default ~2GB heap on
# macOS arm64 runners while Tauri runs beforeBuildCommand.
NODE_OPTIONS: --max-old-space-size=4096
jobs:
build:
name: Build (${{ matrix.arch }})
runs-on: macos-15
strategy:
fail-fast: true
matrix:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
- name: Set version
run: |
VERSION="${{ inputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ inputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
MACOS_BUNDLES="${{ inputs.macos_bundles }}"
if [ -n "$MACOS_BUNDLES" ]; then
pnpm tauri build --target ${{ matrix.target }} --bundles "$MACOS_BUNDLES"
else
pnpm tauri build --target ${{ matrix.target }}
fi
- name: Upload .dmg
if: ${{ inputs.upload_macos_dmg }}
uses: actions/upload-artifact@v4
with:
name: dmg-${{ matrix.arch }}
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
retention-days: 1
- name: Upload updater artifacts (.tar.gz + .sig)
uses: actions/upload-artifact@v4
with:
name: updater-${{ matrix.arch }}
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ inputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Build Tauri app (Linux bundles)
env:
APPIMAGE_EXTRACT_AND_RUN: 1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ inputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
appimages=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
)
installers=(
"${appimages[@]}"
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#appimages[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage bundle."
exit 1
fi
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
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
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ inputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
id: windows-signing
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
REQUIRE_WINDOWS_AUTHENTICODE: ${{ inputs.require_windows_authenticode }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
has_certificate=false
has_password=false
if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE" ] || [ -n "$WINDOWS_CERTIFICATE" ]; then
has_certificate=true
fi
if [ -n "$WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD" ] || [ -n "$WINDOWS_CERTIFICATE_PASSWORD" ]; then
has_password=true
fi
if [ "$has_certificate" != "$has_password" ]; then
echo "::error::Windows Authenticode signing is partially configured. Set both certificate and password secrets, or remove both for unsigned alpha Windows artifacts."
exit 1
fi
if [ "$has_certificate" = "true" ]; then
echo "authenticode_available=true" >> "$GITHUB_OUTPUT"
elif [ "$REQUIRE_WINDOWS_AUTHENTICODE" = "true" ]; then
echo "::error::WINDOWS_CODE_SIGNING_CERTIFICATE or WINDOWS_CERTIFICATE is required to Authenticode-sign Windows installers."
exit 1
else
echo "::warning::Windows Authenticode certificate secrets are not configured. Building alpha Windows artifacts without Authenticode signatures; Tauri updater signatures are still required."
echo "authenticode_available=false" >> "$GITHUB_OUTPUT"
fi
- name: Prepare Windows Authenticode signing
if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }}
shell: pwsh
env:
WINDOWS_CODE_SIGNING_CERTIFICATE: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE }}
WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_PASSWORD }}
WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT }}
WINDOWS_CODE_SIGNING_TIMESTAMP_URL: ${{ secrets.WINDOWS_CODE_SIGNING_TIMESTAMP_URL }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
WINDOWS_CERTIFICATE_THUMBPRINT: ${{ secrets.WINDOWS_CERTIFICATE_THUMBPRINT }}
WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL }}
run: ./.github/scripts/configure-windows-authenticode.ps1
- name: Build Tauri app (Windows bundles)
shell: pwsh
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ inputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
if ("${{ steps.windows-signing.outputs.authenticode_available }}" -eq "true") {
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis --config src-tauri/tauri.windows-signing.conf.json
} else {
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
}
- name: Validate Windows Authenticode signatures
if: ${{ steps.windows-signing.outputs.authenticode_available == 'true' }}
shell: pwsh
run: |
$expectedThumbprint = $env:WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT
if ([string]::IsNullOrWhiteSpace($expectedThumbprint)) {
throw "WINDOWS_CODE_SIGNING_CERTIFICATE_THUMBPRINT was not exported by the signing setup step."
}
$expectedThumbprint = ($expectedThumbprint -replace "\s", "").ToUpperInvariant()
$paths = @()
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "*.exe" -File -ErrorAction SilentlyContinue
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis" -Filter "*.exe" -File -ErrorAction SilentlyContinue
$paths += Get-ChildItem -Path "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi" -Filter "*.msi" -File -ErrorAction SilentlyContinue
$paths = @($paths | Sort-Object FullName -Unique)
if ($paths.Count -eq 0) {
throw "No Windows executable or installer artifacts found to verify."
}
foreach ($path in $paths) {
$signature = Get-AuthenticodeSignature -FilePath $path.FullName
if ($signature.Status -ne "Valid") {
throw "Invalid Authenticode signature for $($path.FullName): $($signature.Status)"
}
if ($null -eq $signature.SignerCertificate) {
throw "Missing signer certificate for $($path.FullName)."
}
$actualThumbprint = ($signature.SignerCertificate.Thumbprint -replace "\s", "").ToUpperInvariant()
if ($actualThumbprint -ne $expectedThumbprint) {
throw "Unexpected signer thumbprint for $($path.FullName): $actualThumbprint"
}
Write-Host "Authenticode signature OK: $($path.FullName)"
}
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ inputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1

View File

@@ -6,13 +6,6 @@ on:
- 'stable-v*'
- 'v20*'
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# The production Vite bundle can exceed Node's default ~2GB heap on
# macOS arm64 runners while Tauri runs beforeBuildCommand.
NODE_OPTIONS: --max-old-space-size=4096
concurrency:
group: release-stable-${{ github.ref }}
cancel-in-progress: true
@@ -63,471 +56,28 @@ jobs:
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build release bundles in parallel
# ─────────────────────────────────────────────────────────────
build:
name: Build (${{ matrix.arch }})
# -------------------------------------------------------------
# Phase 2: Build shared release artifacts
# -------------------------------------------------------------
build-artifacts:
name: Build release artifacts
needs: version
runs-on: macos-15
strategy:
fail-fast: true
matrix:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target ${{ matrix.target }}
- name: Upload .dmg
uses: actions/upload-artifact@v4
with:
name: dmg-${{ matrix.arch }}
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
retention-days: 1
- name: Upload updater artifacts (.tar.gz + .sig)
uses: actions/upload-artifact@v4
with:
name: updater-${{ matrix.arch }}
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
needs: version
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Build Tauri app (Linux bundles)
env:
APPIMAGE_EXTRACT_AND_RUN: 1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
appimages=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
)
installers=(
"${appimages[@]}"
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#appimages[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage bundle."
exit 1
fi
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
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
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1
uses: ./.github/workflows/release-build-artifacts.yml
with:
version: ${{ needs.version.outputs.version }}
macos_bundles: ""
upload_macos_dmg: true
# One-time stable promotion exceptions while Windows certificate secrets
# are still being provisioned. Future stable tags must keep Authenticode.
require_windows_authenticode: ${{ !contains(fromJson('["v2026-06-01","v2026-06-06"]'), needs.version.outputs.tag) }}
secrets: inherit
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (stable)
needs: [version, build, build-linux, build-windows]
needs: [version, build-artifacts]
runs-on: ubuntu-latest
permissions:
contents: write

View File

@@ -10,13 +10,6 @@ on:
- ".github/workflows/release.yml"
- "site/**"
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# The production Vite bundle can exceed Node's default ~2GB heap on
# macOS arm64 runners while Tauri runs beforeBuildCommand.
NODE_OPTIONS: --max-old-space-size=4096
concurrency:
group: release-alpha-${{ github.ref }}
cancel-in-progress: true
@@ -123,467 +116,27 @@ jobs:
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
echo "### Alpha version: \`$DISPLAY_VERSION\` (\`$VERSION\`)" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build each architecture in parallel
# tauri build handles signing automatically via env vars
# ─────────────────────────────────────────────────────────────
build:
name: Build (${{ matrix.arch }})
# -------------------------------------------------------------
# Phase 2: Build shared release artifacts
# -------------------------------------------------------------
build-artifacts:
name: Build release artifacts
needs: version
runs-on: macos-15
strategy:
fail-fast: true
matrix:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
uses: ./.github/workflows/release-build-artifacts.yml
with:
version: ${{ needs.version.outputs.version }}
macos_bundles: app
upload_macos_dmg: false
require_windows_authenticode: false
secrets: inherit
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
# Alpha releases only need the notarized app bundle and updater tarball.
# Skipping DMG packaging avoids fragile bundle_dmg.sh failures on macOS runners.
pnpm tauri build --target ${{ matrix.target }} --bundles app
- name: Upload updater artifacts (.tar.gz + .sig)
uses: actions/upload-artifact@v4
with:
name: updater-${{ matrix.arch }}
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
needs: version
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
fcitx5-frontend-gtk3 \
libfuse2 \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Build Tauri app (Linux bundles)
env:
APPIMAGE_EXTRACT_AND_RUN: 1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
appimages=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
)
installers=(
"${appimages[@]}"
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#appimages[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage bundle."
exit 1
fi
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
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
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# No lipo/re-signing — use the per-arch artifacts directly
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (alpha)
needs: [version, build, build-linux, build-windows]
needs: [version, build-artifacts]
runs-on: ubuntu-latest
permissions:
contents: write

109
AGENTS.md
View File

@@ -1,73 +1,22 @@
# AGENTS.md — Tolaria App
> Quick links: [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
## 1. Development Process
---
## 1. Task Workflow
### 1a. Pick up a task
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
### Start working on a task
**Before writing a single line of code:** run `mcp__codescene__code_health_score` to check the current codebase health against `.codescene-thresholds`. If the score is already below the threshold, **stop and refactor first** — find the worst files with the MCP, improve them, commit, then start the task. Never start feature work on a codebase that is already below the gate.
- Read task description and all comments fully
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
- Check `docs/adr/` for relevant architecture decisions before structural choices
- Add a comment: `🚀 Starting work on this task. [Brief description of approach]`
### 1b. Implement
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- **⛔ NEVER use --no-verify**
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode. You don't need Pencil to use it you can open it as a JSON file.
### 1c. When done
**Phase 1 — Playwright (only for core user flows):**
Write Playwright test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. The curated `pnpm playwright:smoke` suite must stay under **5 minutes**; use `pnpm playwright:regression` for the full Playwright pass.
```bash
pnpm dev --port 5201 &
sleep 3
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```
**Phase 2 — Native app QA:**
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use computer-use/browser-control style interaction for native UI QA when available: click, hover, drag, select, scroll, and type the way a real user would with the mouse and trackpad. For every UI feature, test the primary mouse-driven path first, then verify any relevant keyboard shortcut or keyboard-first workflow still works. Tolaria is still a keyboard-first app, but QA must not assume users only interact by keyboard.
Use `osascript` for app focus, keyboard shortcuts, and keyboard-specific checks. **⚠️ WKWebView:** `osascript keystroke` can be blocked inside editor content — use computer use for native editor interaction when possible, and rely on Playwright for deterministic text-input coverage. Write result as Todoist comment (✅ or ❌).
After both phases pass, add a **completion comment** to the Todoist task. The comment must include:
- What was implemented (a few lines covering logic and UX/UI)
- QA: what was tested and how (Playwright / native screenshot / osascript)
- Tests/coverage: commands run and final coverage result
- CodeScene: before/after touched-file checks plus final Hotspot and Average scores after push
- Codacy: MCP/CLI scan summary; confirm no new Critical/High findings
- Localization: `pnpm l10n:translate` + `pnpm l10n:validate` result, or "no UI copy changes"
- PostHog: event name(s) added, or why no event was needed
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
- ADRs: any new/updated ADRs (or "none")
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
---
## 2. Development Process
- Check `docs/ARCHITECTURE.md` and `docs/ABSTRACTIONS.md` for relevant structural information
- For UI tasks: study app visual language and components first. Prioritize reusing existing components, assets, and variables over recreating them.
- If working on a Todoist task, add a comment: `🚀 Starting work on this task. [Brief description of approach]`
### Commits & pushes
- Push directly to `main` — no PRs, no branches. Pre-push blocks non-`main` pushes.
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- Pre-push hook runs full check suite (build + tests + core Playwright smoke + CodeScene)
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. **⛔ NEVER use --no-verify**
@@ -133,16 +82,46 @@ Coverage is a release gate, not a vanity metric:
- For bug fixes, add a regression test when practical.
- For new behavior, add targeted coverage close to the changed code; do not rely only on broad E2E coverage.
### UI and native QA
**Phase 1 — Playwright (only for core user flows):**
Write Playwright test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. The curated `pnpm playwright:smoke` suite must stay under **5 minutes**; use `pnpm playwright:regression` for the full Playwright pass.
```bash
pnpm dev --port 5201 &
sleep 3
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```
**Phase 2 — Native app QA:**
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use computer-use/browser-control style interaction for native UI QA when available: click, hover, drag, select, scroll, and type the way a real user would with the mouse and trackpad. For every UI feature, test the primary mouse-driven path first, then verify any relevant keyboard shortcut or keyboard-first workflow still works. Tolaria is still a keyboard-first app, but QA must not assume users only interact by keyboard.
Use `osascript` for app focus, keyboard shortcuts, and keyboard-specific checks. **⚠️ WKWebView:** `osascript keystroke` can be blocked inside editor content — use computer use for native editor interaction when possible, and rely on Playwright for deterministic text-input coverage. Write result as Todoist comment (✅ or ❌).
### Release-readiness checklist
Before pushing or moving a task to In Review, verify and mention the result in the Todoist completion comment:
Before pushing or moving a task to In Review, verify the release gates and add a **completion comment** to the Todoist task. The comment must include:
- CodeScene before/after checked for every touched/new scorable file; final Hotspot and Average pass `.codescene-thresholds`.
- What was implemented (a few lines covering logic and UX/UI).
- QA: what was tested and how (Playwright / native screenshot / osascript).
- Tests/coverage: commands run and final coverage result.
- CodeScene: before/after touched-file checks plus final Hotspot and Average scores after push; final scores must pass `.codescene-thresholds`.
- Coverage commands passed (`pnpm test:coverage` and `cargo llvm-cov ... --fail-under-lines 85`) or the change is docs-only.
- Codacy checked via MCP or `.codacy/cli.sh`; all new Critical/High issues fixed.
- Localization checked: any user-facing copy lives in `src/lib/locales/en.json`, `pnpm l10n:translate` was run, and `pnpm l10n:validate` passes. If no copy changed, say “Localization: no UI copy changes”.
- PostHog checked: meaningful new user actions/events are instrumented with safe metadata; noisy/minor changes explicitly say “PostHog: no event needed because …”.
- Docs/ADRs checked under the rules below.
- Codacy: MCP/CLI scan summary; confirm no new Critical/High findings.
- Localization: any user-facing copy lives in `src/lib/locales/en.json`, `pnpm l10n:translate` was run, and `pnpm l10n:validate` passes. If no copy changed, say “Localization: no UI copy changes”.
- PostHog: meaningful new user actions/events are instrumented with safe metadata; noisy/minor changes explicitly say “PostHog: no event needed because …”.
- Refactoring: any files refactored to meet the CodeScene gate, or "none needed".
- ADRs: any new/updated ADRs, or "none".
- Docs: any updated docs (`ARCHITECTURE.md`, `ABSTRACTIONS.md`, etc.), or "none".
- Demo vault dirt checked: `git status --short -- demo-vault demo-vault-v2` is empty unless fixture changes are intentional.
### ADRs & docs
@@ -153,7 +132,7 @@ After any Tauri command, new component/hook, data model change, or new integrati
---
## 3. Product Rules
## 2. Product Rules
### Demo vault hygiene (`demo-vault/`, `demo-vault-v2/`)
@@ -193,7 +172,7 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
---
## 4. Reference
## 3. Reference
### macOS / Tauri gotchas

View File

@@ -43,7 +43,7 @@ brew install --cask tolaria
### Download from releases
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux. Windows installers are Authenticode-signed; company-managed devices may still require IT approval of the Tolaria publisher before first install.
## Getting started

View File

@@ -175,10 +175,18 @@ interface WorkspaceIdentity {
The status-bar workspace manager edits installation-local identity and mount state. The alias is the durable user-facing namespace for cross-workspace links such as `[[team/projects/alpha]]`; labels and colors are display affordances only. The default workspace controls where new notes and Type files are created; it is not a claim that only one vault is active. When multiple workspaces are enabled, every mounted available workspace participates in the graph and the active Git repository set.
Git-facing renderer code must pass an explicit repository path instead of assuming a single active vault. Changes and Pulse/history display one selected repository at a time, manual commit selects one target repository, and AutoGit checkpoints iterate every active repository. Diff, file history, note saves, and discarded changes resolve the repository from the note's workspace provenance or from the selected Git surface.
Git-facing renderer code must pass an explicit repository path instead of assuming a single active vault. Changes and Pulse/history display one selected repository at a time, manual commit selects one target repository, and AutoGit checkpoints iterate every active repository. Diff, file history, note saves, and discarded changes resolve the repository from the note's workspace provenance or from the selected Git surface. Manual Sync refreshes vault-derived sidebar state and bumps a shared Git history refresh key after successful pulls, including `up_to_date` pulls, while automatic up-to-date checks avoid that heavier reload path.
`useGitFileWorkflows` is the renderer abstraction for note-scoped Git file actions. It translates active tabs, visible entries, and modified-file surfaces into the correct repository path for diff/history commands, deleted-note previews, queued editor diff requests, and discard refresh behavior.
### Tolaria Deep Links
Deep links identify existing vault items with `tolaria://<vault-slug>/<relative-path-with-extension>`. The slug is derived from the registered workspace alias, then label, then path basename; generated links append a stable short hash when two vaults share the same base slug. A manually typed ambiguous base slug is rejected instead of choosing the wrong vault.
The relative path is encoded per segment, preserving `/` as the separator while allowing spaces, Unicode, and reserved characters inside filenames. Decoding rejects `.`, `..`, encoded slashes, backslashes, empty segments, and any resolved path outside the target vault root. Links keep the file extension so Markdown, text, media, PDFs, and other vault files can all route through the same `VaultEntry` lookup.
Deep links are navigation-only. Opening one can focus Tolaria, switch to a registered vault, reload the index once, and open an existing item; it never creates missing files, imports external files, or silently falls back to another vault. v1 links are path-based, so renaming or moving a file changes the canonical link. macOS and Windows are the verified v1 desktop targets; Linux registration is best-effort until package-level QA covers the supported desktop environments.
### File kinds and binary previews
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
@@ -191,6 +199,8 @@ Git-facing renderer code must pass an explicit repository path instead of assumi
Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `<img>`, supported audio/video render through native HTML media controls, and supported PDFs render through the webview's PDF object renderer, all backed by Tauri asset URLs. On Linux AppImage builds, `should_use_external_media_preview` can disable in-webview audio/video rendering so the same file blocks show filename/external-open fallback controls instead of triggering unstable WebKitGTK media playback. Runtime asset access is accumulated only for vault roots Tolaria has loaded in the current app session, because Tauri directory forbids cannot be safely reversed after a vault switch. The "open in default app" action re-enters the active-vault command boundary through `open_vault_file_external` before delegating to the native opener. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
Markdown note PDF export is not a stored file-kind transformation. `src/utils/notePdfExport.ts` temporarily marks the current webview for print-only rendering, asks for a `.pdf` filesystem destination only when the native capability reports direct save support, and invokes Tauri's native `WKWebView` PDF export command on macOS. Windows/Linux Tauri builds and browser mode keep print-dialog fallback behavior. `src/components/useEditorPdfExport.ts` ensures the rich rendered note is active before export, so frontmatter is ignored and the PDF reflects the current rendered editor DOM while leaving the vault file unchanged.
### Note Content Freshness
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. Background note prefetch is bounded to a small number of concurrent native reads, and a note opened while queued is promoted to foreground instead of waiting behind the prefetch backlog. Note-open entry objects are re-normalized at the tab boundary, so transient reload or bridge payloads with missing display metadata fall back to filename/title defaults before editor chrome renders; entries without a usable path are ignored instead of opening a broken tab.
@@ -359,6 +369,12 @@ Saved Views live as YAML files under `views/`. Their definition includes user-vi
In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-owned `rootPath` and `workspace` provenance. `SidebarSelection.kind === 'view'` can include that `rootPath`, and view identity is `(rootPath, filename)` rather than filename alone. This lets two vaults both expose `views/focus.yml` without colliding in sidebar selection, note-list filtering, counts, sort/column persistence, edit, or delete flows. A saved View with `rootPath` filters only entries from its own workspace and persists changes through `save_view_cmd` / `delete_view_cmd` against that source vault.
`useAppViewActions()` owns the renderer-side saved View lifecycle: choosing the target workspace, preserving mounted-view identity, saving/deleting YAML definitions, reloading affected vault state, and exposing the available note-list fields for the create/edit dialog. `App.tsx` wires those callbacks into `Sidebar`, `NoteList`, `CreateViewDialog`, and command surfaces without duplicating the persistence rules.
`useMcpSetupDialogController()` owns MCP setup dialog state, busy actions, and manual config callbacks so `App.tsx` only passes the controller into settings/status surfaces. `useAiWorkspaceWindowBridgeEvents()` owns native AI-workspace event subscriptions and listener cleanup for popped-out workspace windows.
`createCrossWindowPersistedStore()` is the shared renderer primitive for AI workspace state that must stay synchronized across the main window and popped-out workspace windows. It owns localStorage reads/writes, BroadcastChannel publishing, storage-event synchronization, and external-store subscribers; domain modules such as `aiWorkspaceSessionStore` and `aiWorkspaceWindowSharedContext` provide sanitizers and mutations around that shell.
The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar treats saved View rows like Type rows for direct customization: double-click starts inline rename, right-click opens edit/rename/icon-color/delete actions, and keyboard users can open that same menu from the focused row while command-palette actions remain responsible for saved View ordering.
### Neighborhood Mode
@@ -415,7 +431,7 @@ Renderer attachment paths are normalized through `src/utils/vaultAttachments.ts`
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with the active mounted workspace set in `VAULT_PATHS`, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints accept explicit `VAULT_PATH`/`VAULT_PATHS` for app-owned or legacy launches; durable external registrations omit vault env and resolve the current mounted workspace set from Tolaria's `vaults.json` at tool-call time. `mcp-server/tool-service.js` owns the shared tool semantics for active-vault resolution, cross-vault lookup/search, note-creation defaults, vault listing, and UI action intents; `mcp-server/index.js` and `mcp-server/ws-bridge.js` remain transport adapters around that service. Manual MCP config export uses the same packaged `mcp-server/` resolver as registration and app-managed AI agents, including Windows executable-adjacent installs under `%LOCALAPPDATA%\Tolaria`, so the copied snippet stays durable across active-workspace changes without writing third-party config files. Vault context checks each active workspace root for `AGENTS.md` and returns those instructions alongside note counts, folders, and recent notes. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
### Vault Caching
@@ -500,6 +516,7 @@ interface PulseCommit {
| `history.rs` | File history | `git log` — last 20 commits per file |
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
| `file_url.rs` | File URL | Builds a copyable remote URL from the primary remote, current branch, and vault-relative path without exposing remote credentials |
| `commit.rs` | Commit | Ensures a local author fallback when needed, then runs `git add -A && git commit -m "..."`; broken signing helpers trigger one unsigned retry for the same app-managed commit |
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
| `connect.rs` | Add remote | Adds `origin`, fetches it, validates history compatibility, and only starts tracking when the remote is safe |
@@ -515,7 +532,7 @@ interface PulseCommit {
- Refreshes aggregate remote status after a pull, and avoids a separate startup status fetch when the initial pull will already refresh it
- Pushes the active repository set during divergence recovery
- Awaits the post-pull vault refreshes so toasts land after note-list state is fresh
- Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor
- Reopens the clean active tab from disk only when the pull changed that active note, then restores editor focus if the editor owned focus before the remount
- Detects merge conflicts → opens `ConflictResolverModal`
- Tracks aggregate remote status (ahead/behind via `git_remote_status`)
- Handles push rejection (divergence) → sets `pull_required` status
@@ -524,7 +541,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 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. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes.
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 when the changed-path list includes that note, and closes the active tab if the file disappeared. Editor focus does not block the clean active note from converging to disk when its own file changed externally; if the active editor owned focus before that remount, the app requests editor focus again after the fresh tab is mounted. 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. Overlapping entry reloads and modified-file polls are coalesced with a single trailing rerun so watcher and sync bursts do not stack native vault scans or Git status processes.
`useGitRepositories` is the commit-time companion to `useAutoSync`:
- Owns repository picker validation plus `get_modified_files` and `git_remote_status` loading for active Git repositories
@@ -580,6 +597,7 @@ 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.
- Missing common grammars live in `src/utils/codeBlockLanguageCatalog.ts` and are registered lazily from direct `@shikijs/langs` imports by `src/components/codeBlockOptions.ts`; known aliases such as `ps1` and `vb` normalize to canonical picker values during Markdown import.
- 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.
@@ -589,6 +607,7 @@ Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and s
- `$...$` becomes a `mathInline` schema node and line-owned `$$...$$` / multiline `$$` blocks become `mathBlock` nodes.
- The rich editor renders both node types through KaTeX with `throwOnError: false`, so malformed formulas keep their source visible instead of breaking the note.
- Double-clicking rendered display math edits the math block's `latex` property in-place; Markdown delimiters remain owned by serialization. Inline math can still be reopened as source text for direct editing.
- `serializeMathAwareBlocks()` converts math nodes back to Markdown delimiters before save, raw-mode entry, and editor-position snapshots.
- Raw CodeMirror mode always shows the plain Markdown source, so imported technical notes stay editable outside Tolaria.
@@ -611,6 +630,7 @@ Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdow
- 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.
- Whiteboard prop writes re-resolve the live BlockNote block by id before mutating it, and disappear as no-ops if a note reload or mode switch has already removed that block.
- The tldraw runtime receives Tolaria's resolved light/dark mode as its user color scheme, so embedded whiteboards follow the app appearance and update while mounted.
- Embedded whiteboards expose a session-only full-window workspace that reuses the same tldraw store and Markdown snapshot; expanding or closing it does not persist camera, tool, or size state.
- 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.
@@ -620,10 +640,12 @@ 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.
- `SingleEditorView` owns a whitespace mouse-selection bridge around BlockNote and its rich-editor scroll area: drag starts that land outside the editable text DOM are remapped through the ProseMirror view with clamped coordinates, while drags below the rendered document fall back to the document end. Drags that begin inside BlockNote's contenteditable surface, toolbars, side menu, dialogs, or non-primary mouse buttons stay on BlockNote/native handling.
- `editorRichCopy.ts` owns rich-editor copy serialization for external apps. Normal selections use BlockNote's external clipboard HTML so tables, lists, checklists, and inline marks paste as rich content outside Tolaria, while `SingleEditorView` still normalizes `text/plain` and keeps fenced code-block selections on raw code text.
- 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.
- `createImeCompositionKeyGuardExtension()` intercepts composing `Enter` keydown events before BlockNote's list shortcuts see them, so Korean/Japanese/Chinese IMEs can commit text at the start of list items without Tolaria splitting the current bullet. It stops editor shortcut propagation only; it does not prevent the browser/IME default composition action.
- `richEditorInputTransform.ts` is the shared execution shell for rich-editor Markdown `beforeinput` transforms. It reads the live ProseMirror view, skips IME composition, resets state when a stale view is detected, dispatches transform transactions, prevents native input only after successful dispatch, and reports recoverable editor-transform errors through the same telemetry path. Arrow ligatures, inline math conversion, and `==highlight==` keep their syntax-specific matching in their feature files and are composed for the main editor by `richEditorInputTransformExtension.ts`.
- `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, 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.
@@ -631,7 +653,8 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- `SingleEditorView` wraps the BlockNote surface in a narrow render-recovery boundary for BlockNote's transient `Block doesn't have id` node-view failure. The boundary retries the BlockNote view once, records `editor_render_recovered`, and marks the recovered error so the React root handler does not send that handled case back to Sentry. Other render errors still propagate through the normal root error path.
- `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.
- `tauriEventCleanup.ts` owns safe Tauri event unlisten cleanup. Hooks and stream utilities route listener teardown through it so stale or duplicate native listener removals cannot surface as unhandled promise rejections during fast remounts, window teardown, or stream completion.
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription 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.
### Markdown-to-BlockNote Pipeline
@@ -729,6 +752,7 @@ 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.
- **Anchored dropdowns**: Fixed-position property menus and note-list sort menus use `src/components/anchoredDropdown.ts` for anchor measurement, viewport clamping, scroll/resize repositioning, and optional max-height calculations. Property-specific filtering and keyboard navigation stay in `propertyDropdownUtils.ts`.
- **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.
@@ -762,6 +786,8 @@ interface SearchResult {
- Click result to open note in editor
- Shows relevance score and snippet
The NoteList header search keeps its local title/snippet/property filtering for immediate scoped results, then augments the match set with `search_vault` hits from the visible workspace roots using the command's frontmatter-excluding search option. React stores only matching paths so body-only matches appear in the current list scope without a second content-read pass or rendering private matched text in note rows.
No indexing step required — search runs directly against the filesystem.
## Vault Management
@@ -795,6 +821,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
- `GEMINI.md` is an optional Gemini CLI compatibility shim that points Gemini back to `AGENTS.md`
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
- `restore_vault_ai_guidance` repairs only Tolaria-managed files and creates the optional Gemini shim on explicit request; user-authored custom `AGENTS.md` / `CLAUDE.md` / `GEMINI.md` files are surfaced as custom and left untouched
- Editing a usable `AGENTS.md`, including changing its frontmatter `type`, makes the file custom rather than broken; broken is reserved for missing, empty, frontmatter-only, unreadable, or exact replaceable managed templates/stubs
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
Vault guidance is intentionally short and vault-specific. General Tolaria product behavior is delivered through the bundled agent docs resource instead:
@@ -828,6 +855,8 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
- Only shows after vault onboarding has already resolved to a ready state
- Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro by treating the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources
- App-managed Claude Code runs preserve the same user-managed Anthropic/provider env behavior by forwarding selected exported variables from the app process or the user's zsh/bash startup files without persisting those secrets
- The shared `useAiAgentsStatus` hook defers that command until after the first render, skips it when AI features are disabled or the current window cannot render AI status surfaces, and falls back to missing-agent statuses if the native probe does not return promptly so first-launch onboarding keeps a recovery path
- Persists dismissal locally once the user continues
### Remote Git Operations
@@ -883,7 +912,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/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `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. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `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. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `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. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. 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.
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/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `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. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `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. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `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; env-backed keys can come from the app process or exported zsh/bash startup values on Unix. Direct OpenAI-compatible model streams receive the active vault root and may execute Tolaria's native create-only `create_note` tool, but they do not receive shell access or general file-write tools. `ai_workspace_conversations` stores installation-local AI chat sidebar metadata only: conversation ids, titles, archive state, and explicit target overrides. It does not store vault content, prompts, transcripts, or model credentials. 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
@@ -931,6 +960,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Windows x64 installers/updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts the selected installer without replacing the page with a blank download navigation. Linux visitors default to the AppImage target while the page exposes RPM as a manual Linux package option when the stable release includes one. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. GitHub alpha tags zero-pad the prerelease sequence (`alpha-vYYYY.M.D-alpha.NNNN`) so GitHub release ordering stays chronological while the shipped app version remains `YYYY.M.D-alpha.N`. Publishes `alpha/latest.json` with macOS Apple Silicon/Intel, Linux x64, and Windows x64 updater entries, then refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed. The Windows job builds NSIS with Tauri updater signatures and uses Authenticode signing plus `Get-AuthenticodeSignature` verification when CI certificate secrets are configured; stable remains the strict channel for mandatory Authenticode enforcement. The Linux job uses Tauri's stock linuxdeploy AppImage output plugin and validates that installer and updater-signature artifacts exist before upload. The docs/release Pages job reads the stable manifest from the latest stable release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so the public updater JSON changes as part of the release workflow. Changes to the shared artifact workflow are not ignored by the alpha trigger, so release-pipeline fixes produce a fresh alpha run. macOS release assets use `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed version as `VITE_SENTRY_RELEASE`, which is retained as a diagnostic build-version tag but not registered as a normal Sentry release for alpha builds.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`, macOS Apple Silicon and Intel DMG/updater artifacts, Authenticode-signed Windows x64 installers plus Tauri-signed updater bundles, Linux x86_64 `.deb` / `.rpm` / AppImage artifacts, and a static public download page that starts selected non-Windows installers without replacing the page with a blank download navigation. Windows visitors see an explicit signed-installer action and managed-device approval guidance instead of an automatic download. Linux visitors default to the AppImage target while the page exposes RPM as a manual Linux package option when the stable release includes one. The Linux job uses the same stock Tauri/linuxdeploy AppImage packaging and artifact validation as alpha releases. The Pages job reads the alpha manifest from the latest alpha release asset instead of copying the live Pages URL, uploads the built site as a Pages artifact, and deploys it with GitHub's official Pages action so stable and alpha manifests stay fresh. Stable macOS DMG/updater assets use the same `Tolaria_<version>_macOS_Silicon` and `Tolaria_<version>_macOS_Intel` base names. Packaged builds pass the computed stable version as `VITE_SENTRY_RELEASE`, which is registered as Sentry's release.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -98,7 +98,7 @@ flowchart LR
#### External Change Detection
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and the clean active editor all refresh under the ADR-0071 unsaved-edit rules. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
The main window starts a native watcher for the active vault through `start_vault_watcher` / `stop_vault_watcher` (`src-tauri/src/vault_watcher.rs`, backed by Rust `notify`). The watcher emits `vault-changed` events for content paths and ignores churn from `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`. `useVaultWatcher` batches those events, suppresses recent app-owned saves, and sends the remaining external paths through `refreshPulledVaultState()` so folders, saved views, note-list state, and clean active-editor content refresh under the ADR-0135 unsaved-edit rules. When that clean active-editor remount starts from a focused editor, the main app dispatches a post-replacement editor focus request so typing can continue in the refreshed tab. `useVaultLoader.isReloading` drives the status-bar reload spinner for both manual and watcher-triggered reloads.
#### Progressive Vault Loading
@@ -110,6 +110,8 @@ Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.m
The registered vault list can act as a mounted-workspace set. `useVaultSwitcher` persists each workspace's installation-local identity (`label`, stable `alias`, color, mount flag) and the default destination for newly created notes in `~/.config/com.tolaria.app/vaults.json`. `useVaultLoader` scans every available mounted workspace and annotates each `VaultEntry` with provenance before React consumes the combined graph. The default workspace is the write target for new notes and Type documents; it is not the only active vault when multiple workspaces are enabled.
Vault item deep links use the registered vault list as their resolver namespace. `src/utils/deepLinks.ts` builds `tolaria://<vault-slug>/<relative-path-with-extension>` URLs from workspace aliases, labels, and paths, appending a short stable hash when generated slugs would collide. `useDeepLinks` validates incoming links, switches vaults when required, reloads the vault index once for recently changed files, and opens the matching `VaultEntry` through the normal note-selection path.
Saved Views participate in that mounted graph as source-scoped chrome. `useVaultLoader` loads view definitions from every mounted vault, annotates each `ViewFile` with its owning `rootPath` and workspace identity, and keeps sidebar selection/persistence keyed by `(rootPath, filename)` so same-named view files from different vaults stay independent.
Git surfaces resolve repository paths explicitly. `useGitRepositories` derives the active repository set from the mounted available workspaces, keeps separate selected repositories for Changes, Pulse/history, and manual commits, and exposes the combined modified-file count for status/commands. AutoGit checkpoints iterate that repository set, while manual commit, history, diff, and discard operations use the selected surface or the note's workspace provenance.
@@ -132,6 +134,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr
| Frontend | React + TypeScript | React 19, TS 5.9 |
| Editor | BlockNote | 0.46.2 |
| Code block highlighting | @blocknote/code-block | 0.46.2 |
| Additional code grammars | @shikijs/langs | 3.23.0 |
| Diagram rendering | Mermaid | 11.14.0 |
| Whiteboard rendering | tldraw | 4.5.10 |
| Raw editor | CodeMirror 6 | - |
@@ -226,10 +229,12 @@ 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 for single-vault lists: 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. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. 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. Folder creation sends the selected folder's vault-relative path and mounted root to `create_vault_folder`, so a new folder is created under the focused parent instead of defaulting to the active vault root. 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. In mounted multi-vault graphs, duplicate type names still render as one sidebar section, but the visibility picker becomes a workspace matrix and writes visibility to the specific vault's Type document, so hidden type definitions suppress only notes of that type from the same workspace.
- **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. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. Folder-backed lists also show non-Markdown files: previewable media 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 filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, 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, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. 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.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, 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` plus lazy direct `@shikijs/langs` registrations for missing common grammars. 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, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. 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. Rich-editor Markdown input transforms for arrows, inline math, and `==highlight==` share one capture-phase `beforeinput` execution path in `src/components/richEditorInputTransform.ts` and are composed by `src/components/richEditorInputTransformExtension.ts`. Navigation history (Cmd+[/]) replaces tabs.
Rich-editor copy uses BlockNote's external HTML serializer for selected note content so tables, lists, checklists, and inline formatting paste richly into other apps, with Tolaria keeping fenced-code selections as raw code text and normalized plain text on the clipboard.
Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and checks the native PDF capability before choosing a platform path. On macOS, the renderer asks for a filesystem PDF destination before the Tauri `export_current_webview_pdf` command saves the current `WKWebView` print operation directly; on Windows/Linux Tauri builds and in browser mode, the same export action falls back to the native/browser print dialog. The export reuses rendered BlockNote output so frontmatter is omitted, while math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified. Markdown notes expose the same export action from Cmd+K, the native Note menu, the breadcrumb overflow menu, and each Markdown row's note-list context menu.
- **Right side panels** (200-500px or hidden): Properties and Table of Contents 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. The breadcrumb bar toggles Table of Contents and Properties actions. 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).
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat.
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat orchestration, sidebar tabs, installed-only target picker, permission-mode picker, and dock/pop-out controls. Header/guidance chrome lives in `AiWorkspaceChrome`, edge-resize handles in `AiWorkspaceResizeHandles`, conversation metadata/settings persistence in `aiWorkspaceConversations`, and sizing/class/style helpers in `aiWorkspaceSizing`. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. AI workspace cross-window localStorage, BroadcastChannel, storage-event, and subscriber-set plumbing is owned by `createCrossWindowPersistedStore`; domain stores keep only sanitization, mutation, and native persistence behavior.
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`.
@@ -237,7 +242,9 @@ 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. 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.
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. AI-agent CLI availability probing is also off the first-paint path: the renderer defers `get_ai_agents_status` until an idle/timeout tick, skips it for disabled AI surfaces and secondary windows, falls back to missing-agent onboarding state if the status IPC does not settle promptly, and the Rust command fans per-agent CLI checks across Tokio's blocking pool with per-agent timeouts. 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.
Desktop startup registers `tauri-plugin-deep-link` and `tauri-plugin-single-instance` before setup so `tolaria://` links can focus the existing main window and deliver URL events to the renderer. `tauri.conf.json` declares the `tolaria` scheme for bundled desktop builds; Windows and Linux also run `register_all()` as a runtime repair path, while macOS relies on bundle registration.
Linux and Windows use custom React-rendered window chrome instead of the native Tauri menu bar. `setup_custom_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 is macOS-only so Services/Hide/Quit and the reserved `WINDOW_SUBMENU_ID` keep behaving like normal NSApp menu items, while cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback from the renderer menu.
On Linux, `run()` applies WebKitGTK startup safeguards before Tauri creates the webview. Native Wayland launches and AppImage launches inject `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, covering compositor-specific WebKit crashes without changing native X11 launches. AppImage launches keep the additional AppImage-only safeguards: on Wayland sessions Tolaria re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. Runtime startup writes a mount-path-specific `GTK_IM_MODULE_FILE` cache when fcitx is configured via `GTK_IM_MODULE=fcitx` or common fcitx environment hints; release packaging currently uses Tauri's stock linuxdeploy AppImage output plugin instead of Tolaria's experimental output-plugin shim. If the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep WebViews from blanking or crashing after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
@@ -273,7 +280,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous permission-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. Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools`, streams line-oriented stdout, drains stderr concurrently, and writes prompt content through stdin to avoid OS argument length limits. Codex, OpenCode, Pi, Gemini, and Kiro all launch from the active vault cwd with transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. 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` after copying and merging the user's Pi agent config, Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`, and Kiro receives it through `.kiro/settings/mcp.json` in the active vault.
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.
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. Claude Code launches also copy a narrow set of exported provider/auth environment variables from the app process or the user's zsh/bash startup files, including Anthropic API/base URL values, so company proxy and API-key setups that work in Terminal also work when Tolaria is opened from Finder or Dock. Windows npm `.cmd` shims are not spawned directly; the shared CLI runtime resolves them to their quoted Node script or native executable target first so prompt arguments do not hit batch-file argument validation.
CLI-agent system prompts also include a local Tolaria docs orientation when the bundled docs resource is present. `scripts/build-agent-docs.mjs` generates `src-tauri/resources/agent-docs/` from the public VitePress Markdown sources, including `index.md`, `AGENTS.md`, per-section bundles, `all.md`, `search-index.json`, and generated per-page files. Tauri bundles that folder as `agent-docs/`; `get_agent_docs_path` resolves the installed resource path, with a repository fallback for development, and `getAgentDocsPath()` caches it before each agent run. Agents are instructed to read the active vault's `AGENTS.md` for local conventions and search the bundled docs for Tolaria product behavior.
@@ -336,17 +343,18 @@ Large active notes are compacted into a head/tail body snapshot before they ente
### 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.
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 shell access. OpenAI-compatible direct targets can use Tolaria's narrow native `create_note` tool when an active vault is loaded; the tool calls the same create-only, active-vault-bounded note write command as the UI and emits tool events so the renderer refreshes and opens the created note. 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.
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. Env-backed provider keys are resolved from the app process first, then from exported values in the user's zsh/bash startup files on Unix so GUI-launched sessions can still use shell-managed secrets. 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; direct provider secrets stay in local app data or user-managed environment variables. App-managed Pi sessions copy that local Pi agent config into a per-run temporary directory before adding Tolaria MCP, so Tolaria does not overwrite global Pi files and does not drop a working standalone Pi setup.
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login or user-managed Anthropic/provider environment variables; 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. App-managed Pi sessions copy that local Pi agent config into a per-run temporary directory before adding Tolaria MCP, so Tolaria does not overwrite global Pi files and does not drop a working standalone Pi setup.
## MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
The stdio entrypoint and desktop WebSocket bridge share `mcp-server/tool-service.js` for mounted-vault resolution, note lookup/search, note creation defaults, vault listing, and UI action intents; `index.js` and `ws-bridge.js` only adapt those semantics to their transport-specific request and response shapes.
### Tool Surface
@@ -354,7 +362,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|------|--------|-------------|
| `open_note` | `path` | Open and read a note by relative path |
| `read_note` | `path` | Read note content (alias for `open_note`) |
| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter |
| `create_note` | `path, content, [title], [type], [vaultPath]` | Create a new markdown note inside an active vault without overwriting existing files |
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
| `list_vaults` | — | List active mounted vaults and whether each has root `AGENTS.md` instructions |
| `append_to_note` | `path, text` | Append text to end of existing note |
@@ -392,12 +400,15 @@ That setup is user-initiated through the status bar / command palette flow, not
flowchart TD
subgraph MCP["MCP Server (Node.js or Bun) — mounted-workspace scoped"]
IDX["index.js"]
SVC["tool-service.js\n(vault resolution, note operations,\nUI action intents)"]
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
WSB["ws-bridge.js"]
IDX -->|"stdio transport"| STDIO["Claude Code / Cursor / Gemini / OpenCode"]
IDX --> VAULT
IDX --> WSB
IDX --> SVC
SVC --> VAULT
IDX -.->|"UI action WebSocket client"| WSB
WSB --> SVC
WSB -->|"port 9710 — tool bridge"| AI["AI Clients\n(Claude Code, external)"]
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
end
@@ -450,6 +461,8 @@ Search is keyword-based, using `walkdir` to scan all `.md` files in the vault di
The `search_vault` Tauri command runs the scan in a blocking Tokio task and returns results sorted by relevance score.
The note-list search field combines client-side scoped filtering with that same command: title, snippet, and visible-property matches resolve immediately, while backend body-content hits use `search_vault` with frontmatter excluded before adding matching paths for the currently visible workspace roots without displaying matched body text in the note row.
## Vault Cache System
The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning using git-based incremental updates.
@@ -532,13 +545,13 @@ If the selected vault disappears after startup, `useVaultLoader` re-checks `chec
When an opened folder is not yet a git repo, Tolaria can show a Git setup dialog with Initialize, Not now, and Never for this vault actions. The Never choice stores a local per-vault `git_setup_preference` so the automatic dialog does not return for that vault, while manual initialization remains reachable from Git commands when global Git features are enabled. Markdown scanning, note browsing, note editing, and search continue normally in plain folders. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git.
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, remote-connection, manual/automatic, and conflict-resolution 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.
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, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures Git can resolve an author identity without overriding one the user configured: it heals the legacy repo-local `vault@tolaria.md` email earlier versions wrote, respects identities resolvable from local, global, or system scope, skips the legacy email wherever it resolves, and only when nothing resolves writes a repo-local `Tolaria <vault@tolaria.default>` fallback. 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.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro CLI are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions, and Tolaria seeds it as an organized `Note` so it stays out of the way in a fresh vault. Optional `GEMINI.md` guidance is created only by the explicit AI guidance restore action. Once a user edits a usable `AGENTS.md`, including changing its frontmatter `type`, the status command treats it as custom guidance instead of broken; repair remains reserved for missing, empty, frontmatter-only, unreadable, or exact replaceable managed templates/stubs. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
After the clone completes, Tolaria removes every configured git remote from the new starter vault. Getting Started vaults therefore open as local-only by default, and users opt into a remote later with the explicit Add Remote flow.
@@ -641,8 +654,10 @@ flowchart TD
RV --> TAB{"clean active tab?"}
TAB -->|Yes| RT["replace active tab\nwith fresh disk content"]
TAB -->|No| DONE["idle"]
RT --> DONE
PC -->|Up to date| DONE["idle"]
RT --> RF["restore editor focus\nwhen previously focused"]
RF --> DONE
PC -->|Manual up to date| RV
PC -->|Auto up to date| DONE["idle"]
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
RS --> RCHK["invoke('git_remote_status')"]
@@ -663,6 +678,8 @@ flowchart TD
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
```
Manual Sync forces a visible-state refresh even when `git_pull` reports `up_to_date`, because the working tree may have already changed through another process while the app still holds stale vault and History state. Updated pulls refresh the vault index, folders, saved views, clean active-editor content, and Git history surfaces; manual up-to-date pulls refresh the vault/sidebar surfaces with unknown changed files and bump the History refresh key without showing a "Pulled 0 updates" toast. Automatic mount/focus/interval up-to-date checks stay cheap and do not reload the vault.
`useGitRemoteStatus` re-checks `git_remote_status` for the default repository, and `useCommitFlow` can resolve remote status for an explicit selected repository when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps that repository's flow local-only: the status bar shows a neutral `No remote` chip for the default repository, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
If the current vault is not a Git repository, Tolaria treats Git as unavailable instead of degraded. With global Git features enabled, the status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup unless the user has chosen not to be prompted automatically for that vault. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders.
@@ -707,7 +724,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|--------|---------|
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
| `git/` | Git operations and shared system-git helpers (`command.rs`, `remote_config.rs`, `commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) |
| `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering |
| `ai_agents.rs` | CLI-agent request normalization and adapter dispatch |
| `cli_agent_runtime.rs` | Shared CLI-agent request, prompt, subprocess, version, and MCP path helpers |
@@ -765,6 +782,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `git_pull` | Pull from remote |
| `git_push` | Push to remote |
| `git_remote_status` | Get branch name + ahead/behind counts |
| `git_file_url` | Build a remote-backed browser/git URL for a vault file |
| `git_add_remote` | Connect a local-only vault to a compatible remote and start tracking it |
| `git_resolve_conflict` | Resolve a merge conflict |
| `git_commit_conflict_resolution` | Commit conflict resolution |
@@ -858,11 +876,17 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
| `useNoteWindowLifecycle` | note-window open/title side effects | Opens `tauri://` note windows without full vault scans and keeps the native title current |
| `useStartupScreenState` | startup visibility booleans | Keeps onboarding, telemetry-consent, missing-vault, and initial indexing decisions out of `App.tsx` |
| `useAppWindowControls` | view mode, panel visibility, command refs, zoom/build labels | Keeps main-window sizing and editor command ref plumbing out of `App.tsx` |
| `useAppViewActions` | saved-view/type creation and saved-view mutation callbacks | Keeps saved-view persistence and Type auto-creation orchestration out of `App.tsx` |
| `useAiWorkspacePublishedContext` | AI workspace note-list snapshot and BroadcastChannel context publishing | Keeps AI workspace context derivation close to its cross-window publication side effect |
| `useMcpSetupDialogController` | MCP setup dialog state/actions | Keeps MCP status, manual config, and connect/disconnect dialog flow out of `App.tsx` |
| `useAiWorkspaceWindowBridgeEvents` | native AI workspace event subscriptions | Owns Tauri listener setup/cleanup for popped-out workspace window bridge events |
| `useGitFileWorkflows` | git diff/history/discard callbacks | Resolves note-scoped repository paths and owns deleted-file preview and queued diff side effects |
| `useVaultRenameDetection` | detected rename banner state | Detects external Git renames on focus and owns the wikilink update callback |
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
| `useDeepLinks` | Deep-link listener and copy actions | Resolves `tolaria://` links into known vault navigation and clipboard URLs |
| `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup |
| `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs |
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
@@ -898,7 +922,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Markdown notes expose "Export note as PDF" from Cmd+K, the native Note menu, the breadcrumb overflow menu, and the note-list context menu, all routed through the same editor export hook. Remote-backed notes expose "Copy git URL" from the breadcrumb overflow and note-list context menu; the renderer gates the action per note workspace via `git_remote_status`, then asks `git_file_url` to build the copied URL from the primary remote, current branch, and vault-relative path. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths.
Shortcut routing is explicit:
@@ -928,18 +952,18 @@ push to main
and a GitHub-sorted tag alpha-vYYYY.M.D-alpha.NNNN
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
→ build job:
→ build-artifacts job, via `.github/workflows/release-build-artifacts.yml`:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin --bundles app
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig updater artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ build-windows job:
→ pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ build-linux job:
→ pnpm install, stamp version
→ tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
→ verify Linux installer and updater-signature artifacts exist
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
→ pnpm install, stamp version, import the Windows code-signing certificate when configured
→ tauri build --target x86_64-pc-windows-msvc --bundles nsis, using the Authenticode signing config when certificate secrets exist
→ verify the Windows app executable and installer Authenticode signatures with Get-AuthenticodeSignature when Authenticode is configured
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ release job:
→ generate alpha-latest.json with darwin-aarch64, darwin-x86_64, Linux, and Windows updater URLs
→ publish GitHub prerelease alpha-vYYYY.M.D-alpha.NNNN named Tolaria Alpha YYYY.M.D.N
@@ -957,17 +981,17 @@ Stable promotions trigger `.github/workflows/release-stable.yml`:
```
push stable-vYYYY.M.D tag
→ version job: validate YYYY.M.D from the tag
→ build job:
→ build-artifacts job, via `.github/workflows/release-build-artifacts.yml`:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
→ pnpm install, stamp version, pnpm build, tauri build --target x86_64-apple-darwin
→ upload signed Apple Silicon and Intel .app.tar.gz + .sig and .dmg artifacts named Tolaria_<version>_macOS_Silicon and Tolaria_<version>_macOS_Intel
→ build-linux job:
→ pnpm install, stamp version
→ tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
→ verify Linux installer and updater-signature artifacts exist
→ upload .deb, .rpm, .AppImage, and signed Linux updater bundles
→ build-windows job:
pnpm install, stamp version, tauri build --target x86_64-pc-windows-msvc --bundles nsis
→ pnpm install, stamp version, import the Windows code-signing certificate
→ tauri build --target x86_64-pc-windows-msvc --bundles nsis with Authenticode signing config
→ verify the Windows app executable and installer Authenticode signatures with Get-AuthenticodeSignature
→ upload NSIS installer, optional MSI artifacts, and signed Windows updater bundles
→ release job:
→ generate stable-latest.json with macOS Apple Silicon, macOS Intel, Linux, and Windows updater URLs plus platform-specific manual download URLs
@@ -976,7 +1000,7 @@ push stable-vYYYY.M.D tag
→ build VitePress public docs into the GitHub Pages root
→ build static HTML release history page at /releases/
→ publish stable/latest.json
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts, default Linux visitors to AppImage, and expose RPM as a manual Linux option when the stable release includes one
→ publish stable/download/ and download/ as permanent download pages that keep the browser page visible while the platform installer starts, default Linux visitors to AppImage, require an explicit Windows installer click with managed-device signing guidance, and expose RPM as a manual Linux option when the stable release includes one
→ preserve alpha/latest.json
→ deploy to gh-pages
```

View File

@@ -53,7 +53,7 @@ Linux release CI currently uses Tauri's stock linuxdeploy AppImage output plugin
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
```
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache.
Release validation verifies that the Linux job produced an AppImage, at least one installer bundle, and updater signature artifacts. Windows release jobs import the CI code-signing certificate, build NSIS with a generated Tauri Authenticode signing config, and verify the app executable plus installer signatures before upload. The experimental AppImage output-plugin shim in `scripts/appimage-launcher-tools.mjs` is retained for local investigation, but it is not wired into release packaging because linuxdeploy currently exits before sealing the AppImage when the shim is pre-seeded in Tauri's tools cache.
## Quick Start
@@ -69,9 +69,14 @@ pnpm dev
pnpm tauri dev
# Run tests
pnpm test # Vitest unit tests
cargo test # Rust tests (from src-tauri/)
pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min)
pnpm test # Vitest unit tests
cargo test # Rust tests (from src-tauri/)
# Or, run Rust tests from root project directory
cargo test --manifest-path src-tauri/Cargo.toml
# E2E tests
pnpm playwright:smoke # Curated Playwright core smoke lane (~5 min)
pnpm playwright:regression # Full Playwright regression suite
```
@@ -102,7 +107,7 @@ tolaria/
│ ├── theme.json # Editor typography theme configuration
│ ├── index.css # Semantic app theme variables + Tailwind setup
│ │
│ ├── components/ # UI components (~98 files)
│ ├── components/ # UI components (~100 files)
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
│ │ ├── SidebarParts.tsx # Sidebar subcomponents
│ │ ├── NoteList.tsx # Second panel: filtered note list
@@ -115,7 +120,9 @@ tolaria/
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
│ │ ├── AiWorkspace.tsx # Multi-chat AI workspace (docked or native window)
│ │ ├── AiWorkspace.tsx # Multi-chat AI workspace orchestration (docked or native window)
│ │ ├── AiWorkspaceChrome.tsx # AI workspace header and vault-guidance chrome
│ │ ├── AiWorkspaceResizeHandles.tsx # AI workspace edge resize handles
│ │ ├── AiPanel.tsx # AI transcript/composer surface (selected target + per-vault permission mode)
│ │ ├── AiMessage.tsx # Agent message display
│ │ ├── AiActionCard.tsx # Agent tool action cards
@@ -223,8 +230,8 @@ tolaria/
│ │ ├── frontmatter/ # Frontmatter module
│ │ │ ├── mod.rs, yaml.rs, ops.rs
│ │ ├── git/ # Git module
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs, connect.rs
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
│ │ │ ├── mod.rs, command.rs, remote_config.rs, commit.rs, status.rs
│ │ │ ├── history.rs, clone.rs, connect.rs, conflict.rs, remote.rs, pulse.rs
│ │ ├── telemetry.rs # Sentry init + path scrubber
│ │ ├── search.rs # Keyword search (walkdir-based)
│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch
@@ -242,7 +249,7 @@ tolaria/
│ └── icons/ # App icons
├── mcp-server/ # MCP bridge (Node.js or Bun)
│ ├── index.js # MCP server entry (stdio, 14 tools)
│ ├── index.js # MCP server entry (stdio tools)
│ ├── vault.js # Vault file operations
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
│ ├── test.js # MCP server tests
@@ -328,7 +335,11 @@ tolaria/
| File | Why it matters |
|------|---------------|
| `src/components/AiWorkspace.tsx` | Multi-chat AI workspace — sidebar chats, installed-only target picker, permission picker, and dock/pop-out controls. |
| `src/components/AiWorkspace.tsx` | Multi-chat AI workspace orchestration — chat sessions, sidebar tabs, target/permission controls, and dock/pop-out wiring. |
| `src/components/AiWorkspaceChrome.tsx` | Header and vault-guidance chrome shared by docked and popped-out AI workspace modes. |
| `src/components/AiWorkspaceResizeHandles.tsx` | Edge resize affordances for docked/side AI workspace layouts. |
| `src/components/aiWorkspaceConversations.ts` | Conversation metadata state, settings persistence, default title generation, and target resolution. |
| `src/components/aiWorkspaceSizing.ts` | AI workspace sizing, localStorage persistence, class names, and layout style helpers. |
| `src/components/AiPanel.tsx` | Reusable AI transcript/composer surface — selected target with tool execution, reasoning, actions, and per-vault permission mode. |
| `src/utils/openAiWorkspaceWindow.ts` | Native Tauri AI workspace window creation, focus, and dock-back traffic-light handling. |
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
@@ -472,6 +483,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts`
5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling.
6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`, `kiro_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi, Gemini, and Kiro on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103. Kiro receives prompt content over stdin and writes Tolaria MCP config into `.kiro/settings/mcp.json` in the active vault.
7. **Availability probing**: Edit `src/hooks/useAiAgentsStatus.ts` and `src-tauri/src/ai_agents.rs` for AI-agent install/status detection. Keep renderer probing deferred until after first paint, skip it when AI features or AI surfaces are unavailable, and keep backend per-agent CLI checks parallel so missing tools do not serialize shell startup cost.
### Work with external MCP setup

View File

@@ -1,53 +0,0 @@
# Large Vault Loading QA
Use this when validating startup responsiveness for large vaults. The goal is to make the bottleneck reproducible without using a real user vault.
## Synthetic Vault
Create a disposable vault with many markdown files:
```bash
VAULT="$(mktemp -d /tmp/tolaria-large-vault.XXXXXX)"
mkdir -p "$VAULT/type" "$VAULT/archive" "$VAULT/assets"
cat > "$VAULT/type/project.md" <<'EOF'
---
is_a: Type
---
# Project
EOF
for i in $(seq -w 1 20000); do
cat > "$VAULT/project-$i.md" <<EOF
---
is_a: Project
status: Active
related_to:
- "[[project-00001]]"
---
# Project $i
Synthetic body $i with enough text to exercise parsing and snippets.
EOF
done
git -C "$VAULT" init
git -C "$VAULT" config user.email qa@example.invalid
git -C "$VAULT" config user.name "Tolaria QA"
git -C "$VAULT" add .
git -C "$VAULT" commit -m "seed large vault"
echo "$VAULT"
```
## Manual QA
1. Start Tolaria with `pnpm tauri dev`.
2. Open the synthetic vault path printed above.
3. Verify the main shell renders before the full note list finishes indexing.
4. Confirm the status bar shows vault activity while indexing is still in progress.
5. Use keyboard-only flows while indexing continues:
- Cmd+K opens the command palette.
- Cmd+P opens quick open; results may be partial or empty until indexing finishes.
- Create a new note with Cmd+N and type in the editor.
6. Wait for indexing to finish and verify the note list/search state is consistent.
The synthetic vault lives under `/tmp`; remove it after QA if it is no longer needed.

View File

@@ -0,0 +1,45 @@
---
type: ADR
id: "0129"
title: "Tolaria vault item deep links"
status: active
date: 2026-05-27
---
# ADR-0129: Tolaria vault item deep links
## Context
Users need durable links they can paste into calendars, task managers, chats, and other apps to return to a Tolaria vault item. The link needs to identify a registered vault, preserve the file extension so non-Markdown files can be opened, and fail clearly when the vault or item is unavailable. Links must not create or import files implicitly.
Mounted workspaces make vault naming non-trivial. A readable slug is useful, but two vaults can share a label, alias, or folder name. URL parsing also cannot rely only on the browser URL implementation because dot-segment normalization can hide path traversal attempts before validation runs.
## Decision
Tolaria deep links use this shape:
```text
tolaria://<vault-slug>/<relative-path-with-extension>
```
The vault slug is generated from the registered workspace alias, then label, then path basename. When two vaults would share the same base slug, generated links append a stable short hash derived from the normalized vault path. A handwritten ambiguous base slug is rejected instead of picking an arbitrary vault.
The path component is encoded per segment with `encodeURIComponent`, so spaces, Unicode, and reserved characters are preserved while `/` remains the path separator. Parsing rejects empty path segments, `.`, `..`, decoded separators inside a segment, unsafe Windows separators, and resolved paths outside the selected vault root.
`src/utils/deepLinks.ts` owns URL building, parsing, and vault resolution. `src/hooks/useDeepLinks.ts` owns renderer integration: it receives Tauri deep-link events, validates them against the registered vault list, switches vaults when needed, reloads once if the target file is not in the current index yet, opens existing Markdown/text/binary entries, reports localized errors, and emits safe PostHog outcomes. Deep links are navigation-only; they never create missing files, import external content, or infer a fallback vault.
The desktop shell registers the `tolaria` scheme through `tauri-plugin-deep-link` and keeps second launches focused through `tauri-plugin-single-instance`. Windows and Linux also call runtime `register_all()` as a repair step. macOS uses bundle scheme registration. Linux runtime registration is best-effort and is not part of the verified v1 support target.
Copy surfaces are shared actions:
- Breadcrumb overflow: `Copy note deeplink`
- Command palette: `Copy deep link to current item`
- File preview header: copy action for non-Markdown vault files
## Consequences
Path-based links are understandable and support every vault file kind, but a file rename changes the old link. A future stable item-id layer could supersede this URL shape while preserving path links as a readable fallback.
Collision handling keeps generated links deterministic without exposing full local paths. Ambiguous handwritten slugs fail clearly, which is safer than opening the wrong vault.
Renderer-owned resolution keeps the navigation logic close to mounted-workspace state and note selection. Native plugins stay responsible only for scheme registration, event delivery, and focusing the existing app instance.

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0130"
title: "Windows Authenticode signing for release installers"
status: active
date: 2026-05-27
---
## Context
Tolaria's Windows release job already produced Tauri updater signatures, but those signatures are not the Windows trust signal used by SmartScreen, Smart App Control, Defender, or WDAC policies when a user downloads and runs an installer from the browser. A managed Windows 11 user reported that the stable NSIS installer was blocked by Windows Security with no bypass option.
Microsoft's current guidance is that unsigned public installers can be fully blocked by enterprise policy, while signed installers at least carry a publisher identity and can build reputation across releases. Store distribution would provide the strongest SmartScreen outcome, but Tolaria does not currently publish a Microsoft Store package.
## Decision
**Tolaria release CI must Authenticode-sign Windows app executables and installers before publishing them.**
- Alpha and stable Windows release jobs import a CI-provided code-signing certificate from GitHub secrets.
- The workflow generates a temporary Tauri config that sets `bundle.windows.certificateThumbprint`, `digestAlgorithm`, and `timestampUrl`, then passes that config to `pnpm tauri build`.
- The Windows job verifies the produced app executable and installer artifacts with `Get-AuthenticodeSignature` and fails before upload if any signature is missing, invalid, or signed by an unexpected certificate.
- The public stable download page requires an explicit Windows installer click and tells managed-device users that IT may need to approve the Tolaria publisher before first install.
## Options considered
- **CI-enforced Authenticode signing** (chosen): gives Windows users and enterprise admins a real publisher identity, lets certificate reputation transfer across releases, and blocks accidental publication of unsigned installers. Cons: release jobs now depend on code-signing secrets and a valid certificate.
- **Documentation-only SmartScreen warning**: cheaper, but it leaves managed-device users with no supported path when policy removes the bypass option.
- **Microsoft Store distribution only**: strongest SmartScreen behavior, but it requires a separate packaging, submission, and release-management path that Tolaria does not yet own.
- **Portable ZIP fallback**: still downloads executable content from the browser and can remain subject to SmartScreen, Mark-of-the-Web, Smart App Control, or WDAC policy.
## Consequences
- Windows release failures caused by missing or expired code-signing credentials are intentional release blockers.
- Tauri updater signatures remain required for in-app updates, but they are treated as separate from Windows Authenticode trust.
- Enterprise-managed Windows installs can be documented around a stable Tolaria publisher identity instead of asking users to disable security policy.
- A future Microsoft Store/MSIX distribution path can supersede or supplement this policy if Tolaria decides to support Store-managed installs.

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0131"
title: "Reusable release artifact build workflow"
status: active
date: 2026-05-28
---
## Context
Tolaria's alpha and stable release workflows both need to build the same platform artifact set: dual-architecture macOS updater bundles, optional stable macOS DMGs, Linux bundles, and signed Windows installers/updater bundles. Keeping those build jobs copied into both release workflows made platform fixes and validation changes easy to apply in one channel while accidentally leaving the other channel behind.
The release workflows still differ in how they compute versions, create releases, and publish alpha vs. stable metadata, but the artifact build contract is shared.
## Decision
**Tolaria centralizes release artifact production in `.github/workflows/release-build-artifacts.yml`, invoked by alpha and stable release workflows through `workflow_call`.** Channel-specific workflows own versioning and publishing; the shared workflow owns platform build, signing, validation, and artifact upload behavior.
## Alternatives considered
- **Reusable artifact workflow** (chosen): keeps alpha and stable artifact behavior aligned while preserving separate channel-specific release orchestration. Cons: release behavior is split across one caller workflow and one called workflow, so debugging requires following both files.
- **Keep duplicated jobs in each release workflow**: makes each workflow self-contained, but every platform build fix must be applied twice and drift is likely.
- **Merge alpha and stable releases into one workflow**: reduces workflow count, but couples different trigger/version/publishing semantics and makes the release pipeline harder to reason about.
## Consequences
Alpha and stable releases now share one platform artifact contract, including macOS, Linux, and Windows validation. Changes to signing, cache keys, bundle validation, or platform matrices should happen in the reusable artifact workflow unless they are genuinely channel-specific.
The architecture documentation should describe the release pipeline as channel orchestration plus shared artifact production, not as independent duplicated build job sets.

View File

@@ -0,0 +1,25 @@
---
type: ADR
id: "0132"
title: "Alpha Authenticode soft gate"
status: active
date: 2026-05-28
amends: "0130"
---
## Context
ADR 0130 made Windows Authenticode signing mandatory for release installers. That is still the right requirement for stable promotions, but the repository does not yet have the Windows code-signing certificate secrets needed by CI. Because alpha releases run on every push to `main`, requiring those secrets there broke the continuous alpha channel before the certificate provisioning work was complete.
## Decision
**Alpha Windows artifacts keep building when Authenticode certificate secrets are absent; stable Windows artifacts still require Authenticode signing.**
- The shared release artifact workflow accepts `require_windows_authenticode`.
- Alpha passes `false`, emits a workflow warning when certificate secrets are absent, and still requires Tauri updater signatures.
- Stable passes `true` and fails before building Windows artifacts unless certificate and password secrets are configured.
- When certificate secrets are present, both channels use the generated Tauri Authenticode config and verify Windows executable/installer signatures before upload.
## Consequences
The alpha updater channel remains live while Windows certificate provisioning is underway. Stable releases continue to enforce the Windows trust policy from ADR 0130 before public promotion. Once the certificate secrets are configured, alpha builds automatically regain Authenticode signing without another workflow change.

View File

@@ -0,0 +1,27 @@
---
type: ADR
id: "0133"
title: "Request-scoped AI stream event channels"
status: active
date: 2026-05-29
---
## Context
AI agent streams and direct model streams used shared Tauri event names (`ai-agent-stream` and `ai-model-stream`). That worked while only one stream of each kind was active, but the side AI workspace and pop-out window can make concurrent or rapidly reused AI sessions more likely. Shared channels risk routing deltas, tool events, or completion events to the wrong renderer listener.
## Decision
**Each native AI stream uses a request-scoped Tauri event channel generated by the renderer and validated by the backend.**
The renderer creates a unique event name with the stream's stable base prefix, listens on that channel, and passes it to the `stream_ai_agent` or `stream_ai_model` command as `event_name`. The Rust command accepts only scoped names that match the expected base prefix and safe character set; invalid or missing names fall back to the legacy shared channel.
## Alternatives considered
- **Request-scoped renderer channel** (chosen): isolates simultaneous streams without changing the stream event payload shape, while preserving backward-compatible command defaults.
- **Keep shared static event names**: simpler, but concurrent agent/model sessions can cross-deliver stream events between workspaces or chats.
- **Backend-generated channel names**: centralizes validation, but requires a setup handshake before the renderer can subscribe and complicates the current fire-and-stream command flow.
## Consequences
Concurrent AI streams can run without contaminating each other's renderer callbacks. The backend keeps a narrow validation boundary for externally supplied event names. Any future AI streaming command should follow the same base-prefix plus scoped-suffix convention instead of adding another process-wide static channel.

View File

@@ -0,0 +1,27 @@
---
type: ADR
id: "0134"
title: "Direct Shiki language registrations for code blocks"
status: active
date: 2026-05-29
---
## Context
Tolaria uses `@blocknote/code-block` for rich-editor fenced-code highlighting. The bundled BlockNote highlighter covers the common web and systems languages already in the editor menu, but it does not include several common Shiki grammars such as PowerShell, VBScript, Dart, Dockerfile, Terraform/HCL, and TOML. Users still expect imported fences like `powershell`, `ps1`, `vb`, and `vbscript` to highlight, show a valid language picker state, and serialize back to a stable Markdown fence.
## Decision
**Tolaria keeps BlockNote's code-block integration and adds direct, lazy `@shikijs/langs` registrations for missing common languages and aliases.**
## Options considered
- **Keep only the BlockNote bundle**: simplest, but leaves PowerShell/VBScript and other common fences unsupported.
- **Register selected `@shikijs/langs` grammars lazily** (chosen): preserves BlockNote's schema and parser path while adding only the extra grammars users need.
- **Replace BlockNote's highlighter with a full custom Shiki bundle**: more control, but a larger structural change than the current requirement needs.
## Consequences
`src/components/codeBlockOptions.ts` remains the owner of the BlockNote highlighter configuration, but extra grammar modules are now imported directly from `@shikijs/langs` only when a matching fence or picker value needs highlighting. `src/utils/codeBlockLanguageCatalog.ts` owns the supported extra language labels and aliases, and `src/utils/codeBlockLanguage.ts` normalizes known imported aliases such as `ps1` and `vb` to the canonical picker language.
The language menu grows, but unsupported aliases still fail safely by staying as plain explicit fence names. If Tolaria later needs a generated language bundle, export-time highlighting, or a substantially smaller menu, this ADR should be superseded by a custom Shiki packaging decision.

View File

@@ -0,0 +1,44 @@
---
type: ADR
id: "0135"
title: "Clean active notes refresh immediately after external edits"
status: active
date: 2026-05-30
supersedes: "0111"
---
## Context
ADR-0111 made external vault refreshes path-aware and preserved focused editor mounts so unrelated watcher events would not disrupt cursor state. That avoided needless remount churn, but it also meant a clean active note edited by Codex or another external process could remain visibly stale while the editor stayed focused. Because no later safe-remount trigger was guaranteed, users could see the old content until a full app restart.
Tolaria's filesystem-first model requires clean in-memory editor state to converge to the file on disk during the current session. Unsaved local editor buffers still need protection, but editor focus alone is not enough reason to keep showing stale content when the changed-path batch identifies the active file.
## Decision
**External vault refreshes now remount a clean active note immediately when the external changed-path batch includes that note, regardless of editor focus.**
The shared `refreshPulledVaultState()` path applies these rules:
1. Reload vault entries, folders, and saved views together for every external change batch.
2. If there is no active note, stop after the shared reload.
3. If the active note changed during the async reload, stop rather than reopening stale context.
4. If the active note has unsaved local edits, keep the current editor buffer mounted.
5. If the active file disappeared, close the tab instead of leaving a stale editor behind.
6. If the changed-path batch includes the clean active file, close and reopen the active tab from disk even when focus is inside the rich or raw editor.
7. Unknown or unrelated change batches refresh vault-derived state without remounting the active editor.
Git pulls, AI-agent refresh callbacks, and filesystem-watcher batches continue to converge through this single reconciliation helper instead of adding separate reload policies.
## Alternatives considered
- **Immediate clean active-note remount** (chosen): restores filesystem convergence for Codex and other external note edits while preserving unsaved local edits. Cons: a focused clean editor can lose cursor state when its own file changes externally.
- **Keep focused-editor preservation from ADR-0111**: avoids cursor disruption, but can leave the active editor stale indefinitely.
- **Defer active-note reload until blur**: reduces focus disruption, but adds another pending-refresh state machine and still allows the active editor to show stale disk content for an unbounded editing session.
## Consequences
- External edits to the currently open clean note become visible without restarting Tolaria.
- Unsaved local content remains authoritative and is not replaced by watcher, pull, or agent refreshes.
- The changed-path batch remains part of the external-refresh contract; callers should pass specific file paths whenever available.
- Unrelated watcher events still avoid active-editor remounts, so broad vault churn does not disturb the editor unless the active file itself changed.
- ADR-0111 is superseded by this stronger filesystem-convergence rule.

View File

@@ -0,0 +1,31 @@
# ADR-0136: macOS Webview PDF Export
## Status
Accepted
## Context
The first note PDF export implementation reused the native webview print command. On macOS that opens the full printer dialog, which is not the product behavior expected from "Export note as PDF"; users should choose a filesystem destination and get a PDF directly.
Tolaria already renders the exportable note in the live BlockNote DOM and applies print-only CSS so math, Mermaid, images, code blocks, tables, links, and custom blocks follow the same rendering path users see in the editor. Introducing a second Markdown-to-PDF renderer would duplicate that rendering logic and create drift.
## Decision
Use the existing Tauri webview and WebKit's own print operation to save the current webview directly to a chosen PDF path. The renderer remains responsible for export preparation:
- exit raw/diff modes
- apply the PDF export body class
- ask the user for a `.pdf` destination
- invoke the native `export_current_webview_pdf` command
The native command uses direct `objc2`, `objc2-app-kit`, `objc2-foundation`, and `objc2-web-kit` dependencies on macOS only. These crates are already part of Tauri's platform stack; declaring them directly lets Tolaria ask `WKWebView` for a WebKit-aware `NSPrintOperation` without adding a separate PDF rendering engine.
Windows, Linux, and browser mode keep print-dialog fallback behavior because they do not have the macOS WebKit/AppKit direct PDF save path yet. The renderer checks the native capability before opening a filesystem save dialog, so unsupported platforms do not ask for a destination that cannot be used.
## Consequences
- The macOS path opens a save-file dialog, not the printer dialog.
- The exported PDF keeps using the rendered note DOM, so frontmatter stays excluded by the existing rich-editor body extraction.
- The feature depends on macOS WebKit/AppKit behavior for direct PDF output. Other desktop platforms use the existing native print dialog until they get a platform-specific direct PDF path.
- The new direct dependencies must stay target-scoped to macOS so Linux and Windows builds do not compile AppKit crates.

View File

@@ -0,0 +1,58 @@
---
type: ADR
id: "0137"
title: "Shared rich-editor input transforms"
status: active
date: 2026-06-07
---
## Context
Tolaria has several Markdown-style conveniences in the rich BlockNote editor:
typed arrows become ligatures, completed inline math becomes a math node, and
completed `==highlight==` syntax becomes the durable highlight mark.
These features were added incrementally as separate `beforeinput` extensions.
Each extension repeated the same lifecycle work: reading the live ProseMirror
view, skipping IME composition, guarding stale views, dispatching transactions,
preventing native input only after a successful transform, and recovering known
BlockNote/ProseMirror transform failures. The syntax matchers differed, but the
execution shell was parallel enough that each new Markdown affordance risked a
slightly different edge-case policy.
## Decision
**Tolaria routes rich-editor Markdown input transforms through one shared
`beforeinput` execution path.**
`src/components/richEditorInputTransform.ts` owns the common lifecycle,
dispatch, and recoverable-error behavior. Feature files such as
`arrowLigaturesExtension.ts`, `mathInputExtension.ts`, and
`markdownHighlightInputExtension.ts` expose small transform objects that only
decide whether the current input event should produce a transaction.
`src/components/richEditorInputTransformExtension.ts` composes the Markdown
transform set used by the main editor and hidden editor probe.
## Options Considered
- **Shared transform primitive with feature-owned matchers** (chosen): removes
duplicate listener, dispatch, composition, and recovery code while keeping each
syntax rule local and testable.
- **One monolithic Markdown input extension**: reduces listener count, but mixes
unrelated syntax rules in one file and makes each future input affordance
harder to test independently.
- **Keep one extension per feature**: preserves local ownership, but keeps the
repeated edge-case shell and invites divergence as more transforms are added.
## Consequences
- `Editor` mounts one Markdown input-transform extension rather than separate
arrow, math, and highlight `beforeinput` listeners.
- Feature-specific files remain responsible for their syntax matching and
transaction construction only.
- Recoverable transform errors use the same telemetry event and fallback policy
across Markdown input transforms.
- New rich-editor Markdown input affordances should plug into the shared
transform primitive instead of adding another capture-phase `beforeinput`
extension.

View File

@@ -164,7 +164,7 @@ proposed → active → superseded
| [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 |
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | superseded → [0121](0121-appimage-external-fallback-for-audio-and-video-previews.md) |
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active |
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | superseded → [0135](0135-clean-active-note-refresh-after-external-edit.md) |
| [0112](0112-system-theme-mode.md) | System theme mode | active |
| [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active |
| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active |
@@ -180,3 +180,11 @@ proposed → active → superseded
| [0126](0126-renderer-action-history.md) | Renderer action history for app-level undo and redo | active |
| [0127](0127-native-ai-workspace-window.md) | Native AI workspace window | superseded -> [0128](0128-lightweight-ai-workspace-window.md) |
| [0128](0128-lightweight-ai-workspace-window.md) | Lightweight AI workspace window | active |
| [0129](0129-tolaria-vault-item-deep-links.md) | Tolaria vault item deep links | active |
| [0130](0130-windows-authenticode-release-signing.md) | Windows Authenticode signing for release installers | active |
| [0131](0131-reusable-release-artifact-build-workflow.md) | Reusable release artifact build workflow | active |
| [0132](0132-alpha-authenticode-soft-gate.md) | Alpha Authenticode soft gate | active |
| [0133](0133-request-scoped-ai-stream-events.md) | Request-scoped AI stream event channels | active |
| [0134](0134-direct-shiki-language-registrations.md) | Direct Shiki language registrations for code blocks | active |
| [0135](0135-clean-active-note-refresh-after-external-edit.md) | Clean active notes refresh immediately after external edits | active |
| [0137](0137-shared-rich-editor-input-transforms.md) | Shared rich-editor input transforms | active |

View File

@@ -7,25 +7,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
<style>
:root {
color-scheme: light;
background: #FFFFFF;
color: #37352F;
}
:root[data-theme="dark"] {
color-scheme: dark;
background: #1F1E1B;
color: #E6E1D8;
}
body {
background: inherit;
color: inherit;
}
</style>
<title>Tolaria</title>
</head>
<body>
<script>
(function () {
function isResizeObserverLoopMessage(message) {
@@ -47,6 +28,63 @@
}
});
try {
if (new URLSearchParams(window.location.search).get('window') === 'ai-workspace') {
document.documentElement.classList.add('ai-workspace-native-window');
}
} catch {
// Leave the normal app chrome untouched if URL parsing is unavailable.
}
})();
</script>
<style>
:root {
color-scheme: light;
background: #FFFFFF;
color: #37352F;
}
:root[data-theme="dark"] {
color-scheme: dark;
background: #1F1E1B;
color: #E6E1D8;
}
body {
background: inherit;
color: inherit;
}
:root.ai-workspace-native-window,
:root.ai-workspace-native-window body {
background: transparent;
height: 100%;
margin: 0;
overflow: hidden;
width: 100%;
}
:root.ai-workspace-native-window body {
box-sizing: border-box;
padding: 20px;
}
:root.ai-workspace-native-window #root {
background: #FFFFFF;
border: 1px solid #E9E9E7;
border-radius: 12px;
box-sizing: border-box;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12), 0 1px 3px rgba(15, 23, 42, 0.10);
height: 100%;
overflow: hidden;
width: 100%;
}
:root.ai-workspace-native-window[data-theme="dark"] #root {
background: #1F1E1B;
border-color: #34322D;
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.30), 0 1px 4px rgba(0, 0, 0, 0.22);
}
</style>
<title>Tolaria</title>
</head>
<body>
<script>
(function () {
var key = 'tolaria-theme';
var legacyKey = 'laputa-theme';
var systemDarkQuery = '(prefers-color-scheme: dark)';

105
lara.lock
View File

@@ -17,6 +17,63 @@ files:
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
command.contribute: 9887a4451812854f0f1b6f669a874307
command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f
menu.application: bc16b1fbe2e90ace4991cafc5149955c
menu.file: 0b27918290ff5323bea1e3b78a9cf04e
menu.edit: 7dce122004969d56ae2e0245cb754d35
menu.view: 4351cfebe4b61d8aa5efa1d020710005
menu.go: 5f075ae3e1f9d0382bb8c4632991f96f
menu.note: 3b0649c72650c313a357338dcdfb64ec
menu.vault: 5b70a213f150f01f0776fa9481ef2ddf
menu.window: c89686a387d2b12b3c729ce35a0bcb5b
menu.file.quickOpen: d79cba7ca4badcccc081bdcba22abd32
menu.file.quickOpenCmdO: 2f1c2a963833b85008562cd615515234
menu.file.quickOpenCtrlO: 8aaba3ce15c61798ce80bda311fc1e2d
menu.file.save: c9cc8cce247e49bae79f15173ce97354
menu.edit.pasteWithoutFormatting: 0219dac5b3b9c457aa65eb19fdd3d22c
menu.edit.findInVault: 7dad10e092a9fe1bfbb4a96c377bc417
menu.edit.toggleNoteListSearch: f6e0cd9529f571609033958a55f18d78
menu.view.allPanels: d33bf99e0756e1d5008cb78902e38ca9
menu.view.zoomIn: f5ca4abce85e2dddb0342d0bae3a7270
menu.view.zoomOut: 30850b501f98539b1aabaa29fabe41ef
menu.view.actualSize: a5dac79ddad1cbdb8b60c5b347ee047e
menu.view.commandPalette: c0a2436cd8b2dc5085c869e4a052572f
menu.go.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5
menu.go.archived: 7d69b3cb4cada18ae61811304f8fbcb5
menu.go.changes: c112bb3542e98308d12d5ecb10a67abc
menu.go.inbox: 3882d32c66e7e768145ecd8f104b0c08
menu.note.toggleOrganized: 003f5648f585407c894e85543a5fda47
menu.note.toggleTableOfContents: c464111f97490e65699fe18970fc00e1
menu.vault.addRemote: 4d9d0f784920d6ef7f9f3b33266dd00e
feedback.title: 96ab5025e38aef43fdb73c9830795264
feedback.description: e900af4efb9167033d4ecc8a02cfe463
feedback.sponsor.title: 2f6a1db54812f51924c3324a178125ed
feedback.sponsor.description: 6517e0c5d4e4801814a6034dd54862b6
feedback.sponsor.cta: a2c71ec076796ab6af0ba0ea882303fc
feedback.sponsor.linkLabel: 7fbe24a8f025219f4d165aeb75352125
feedback.featureRequests.title: 73ea5953d68b2c20a82bb94240f6fe17
feedback.featureRequests.description: 9583575bdef16e12927b0a5acfa84307
feedback.featureRequests.cta: c19ef0158349da249920b0d9f67d7d48
feedback.featureRequests.linkLabel: d125f38d1dc8dbdc7dae3f1778df3a3a
feedback.discussions.title: 560f23c77d499c21038c0b4487f03cb2
feedback.discussions.description: 2e4da4c5f54c7af3b8db9f3d93ed63c5
feedback.discussions.cta: 61ccb6a90ec8037f2926c48c0a90caa3
feedback.discussions.linkLabel: 4f85e3343c5e21d2b11812b2baa8ed83
feedback.contributeCode.title: 8f93fd72b63620711cc118403b85133e
feedback.contributeCode.description: 53230626356951578842a8cd7233d1f1
feedback.contributeCode.cta: c73dd5e7d432cd18c109b333e0b55299
feedback.contributeCode.linkLabel: bfe9dd9816bb39f82fc003de794a075d
feedback.contributingGuide.cta: 6cc1fbcbbc69a70a70a2020c979d8e22
feedback.contributingGuide.linkLabel: b1310938ecab4c502a8b3af2ca1ce188
feedback.reportBug.title: 3406147c3ce729ce17ed76d45c2896f9
feedback.reportBug.description: 6da172412f774e022b0f32ba17305491
feedback.reportBug.cta: 1f5aa905b40956ff5ea8b4cc5ea29315
feedback.reportBug.linkLabel: ffaa2eaa0f015cf037d2b5fe8af6b813
feedback.linkFallback.title: c5396ec50cec23a09977180747737264
feedback.linkFallback.description: 91d1e18130765119606f095ac473d9cd
feedback.copyDiagnostics: dedf5388697bba1fd53173b04823761a
feedback.diagnosticsCopied: 9c7f3f37df9c000fc67c1a6ee35f40e5
feedback.diagnosticsCopiedSentence: 6f8ebe492dbb95827ccf7536965dc672
feedback.clipboardUnavailable: 8e25a424420687ed8e0dc2b1be8c7406
command.group.navigation: 846495f9ceed11accf8879f555936a7d
command.group.note: 3b0649c72650c313a357338dcdfb64ec
command.group.git: 0bcc70105ad279503e31fe7b3f47b665
@@ -59,6 +116,8 @@ files:
command.note.removeIcon: 4bccbad66025e506b37b4218498b299c
command.note.changeType: 4a54ffd47ed1f65ee97f34bfe8c3de14
command.note.moveToFolder: 3a0f26c42b9168ad839960d134065905
command.note.copyDeepLink: 8de1795f59af124594dc4decf77909a1
command.note.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0
command.note.openNewWindow: 6f67b2857093fa0fd45b1122dff4865d
command.git.initialize: 54a57cdc7105db6fb22dca661ce01ad6
command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46
@@ -304,7 +363,7 @@ files:
ai.panel.empty.checkingDescription: 31e41510d851c366860377ba0c956f0b
ai.panel.empty.missingTitle: a2b764da52cb43b191bb4ecfd8ea4d26
ai.panel.empty.missingDescription: 91825c7d86b1d588d44a33e1d94af950
ai.panel.empty.withContextTitle: 66f02985937083e4ef62b98cc9f25aee
ai.panel.empty.withContextTitle: cea2547385aeeb68fa494bb3b09b98f5
ai.panel.empty.noContextTitle: e919aeb0a49b58e494e43e5f6f6597c9
ai.panel.empty.withContextDescription: dd8faefcc8160a2db800a41d6628facb
ai.panel.empty.noContextDescription: 6a6e610835808016d1f2790c3f991a54
@@ -312,11 +371,20 @@ files:
ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9
ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114
ai.panel.send: 747c6a3564774149c989884e0c581d53
ai.workspace.title: d5b381672258b0bb54ede1e886895b3e
ai.message.regenerate: e464a5330788a9f79d06c96989f840cb
ai.message.copy: 53fa249ce3d02c8a686b657b31d02ee7
ai.message.fork: f5aace5e5b5e7b0954651a2da44b6755
ai.message.reasoning: 87a222577e9c7b7a0739d92337f4fe46
ai.message.toolUse: d6093cfe0c95f805875e5724eecd20aa
ai.workspace.title: 375099f0c804e924c1dc275370710d92
ai.workspace.newChat: a917baaf1484ec5dc85f19ab8fc4f4e9
ai.workspace.collapseSidebar: 9ecc763686b7c61547281f3e05ffd06f
ai.workspace.expandSidebar: 281b619f3da2ba27e604302c3c00db56
ai.workspace.close: 184649ab030e9a609488608221c28d8c
ai.workspace.expandPanel: edbe17b531457367c1412e4b6349c194
ai.workspace.restorePanel: 64eb6176c678c6b395cfc93bac02a16f
ai.workspace.restoreGuidance: 2bd339d85ee3b33e513359ce781b60cc
ai.workspace.closeChat: 6c5be35688a466297db40c3a65ac9805
ai.workspace.popOut: 85ec25ec056b3082651e6e3fcfa57416
ai.workspace.dock: 72e81ed23007ec6fb9820b9abec353c4
ai.workspace.settings: 2c178b1d7a60a3c2b37ca4221d322899
@@ -326,7 +394,7 @@ files:
ai.workspace.hideArchived: e7c72757d5bc7330d876eeb1b6ce9c53
ai.workspace.noActiveChats: f0cdbf09f23eb9a2efbb5977bcc39c1a
ai.workspace.noArchivedChats: 68410efd12fc0308ce1ed2225000d4d5
ai.workspace.chatTitle: 2ecae1c7ffb3296a789ff8f3b3ed2922
ai.workspace.chatTitle: 189013cfac88018c68ec02e8edc2379b
ai.workspace.renameChat: 0feba25e166f55afc51c4ebb33d8d757
ai.workspace.status.archived: 7d69b3cb4cada18ae61811304f8fbcb5
ai.workspace.status.idle: e599161956d626eda4cb0a5ffb85271c
@@ -349,6 +417,10 @@ files:
ai.workspace.target.installedVersion: 8677756d761e4cce240a637805482946
ai.workspace.target.localModel: d34f4b997ce78da5aa57d657a5d6fcb5
ai.workspace.target.apiModel: 589a257cbd265eaa7de93de8b4084dff
window.minimize: d27532d90ecd513e97ab811c0f34dbfd
window.maximize: 9369ba148ee259473fc1fb4939b6c2e8
window.restore: 2bd339d85ee3b33e513359ce781b60cc
window.close: d3d2e617335f08df83599665eef8a418
mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01
mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c
mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa
@@ -515,8 +587,34 @@ files:
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
editor.toolbar.copyNoteDeepLink: 0fff274a7c47e7c12457c0b7412e86ce
editor.toolbar.copyNoteGitUrl: 04a732b98f7585e3c3b5474688e0baac
editor.toolbar.exportPdf: 7a39acf8742a59d1a4f7ae064b6219a0
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
editor.formatting.highlight: 0b90582f4589d84be89f5b847d4d1ed1
editor.formatting.highlightTooltip: c27173fea9ec1718030c5fa8440b2c78
editor.whiteboard.enterFullscreen: 606bf766125ecff4b33ca98122222c72
editor.whiteboard.exitFullscreen: afd0a0414d79d047f00319921e4d4944
editor.exportPdf.unavailable: 2fce9834984172b64b66767485d815e9
editor.exportPdf.failed: bf3fa78ff5725f5cbde8979b67ec2e2a
filePreview.copyDeepLink: b75833669968adbef634495636e3fe50
deepLinks.copied: 6ada57681192daf3b4afa07ce7a30575
deepLinks.error.ambiguousVault: 4365c6d5cf8c2c1165259b32d24a6274
deepLinks.error.copyFailed: 57b74ecce2d00b4c327d15830f79410d
deepLinks.error.invalidScheme: 58eaa7ac701a0508cbae90c5d3837ce4
deepLinks.error.malformedUrl: 4f85323322ef0a1d627df621edb749c7
deepLinks.error.missingFile: 79a0c73176508c58b4d785fabaf7d8cc
deepLinks.error.missingPath: a6bb1c92f1a38476ff603a2d3ff81223
deepLinks.error.missingVault: e2936e1f599c7ed229efb5d4a122fc4b
deepLinks.error.openFailed: 97901feafe25688fcc3fc4dcfaf82619
deepLinks.error.outsideVault: fda2fd5675b596df7ba0e0b1a11ec47d
deepLinks.error.unavailableVault: 911dfaf8e855bd8cadefe7df9a98a7e8
deepLinks.error.unknownVault: b4940569e884833f50466177dab1f62f
deepLinks.error.unsafePath: e09caab43be8c2372bfc94e947ea34e6
noteGitUrls.copied: 6832bf85abaa03a6304cd1cce073d747
noteGitUrls.error.copyFailed: 1952ee267f473efe8e28ca90659f75f6
noteGitUrls.error.unavailable: ed08c134bba26350fd28f780c6f1b55b
editor.slash.math: a49950aa047c2292e989e368a97a3aae
tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b
tableOfContents.close: d3292972a10edd1a06a627f3552f760a
@@ -709,3 +807,4 @@ files:
locale.plPL: c730389bc8d99e59c867766babdd48b5
locale.beBY: 0a6de4460095f398e8a45ed512a7538f
locale.beLatn: 9c454dccccd6aaac0121a45b923dc5ad
locale.idID: cc1c9b57607b4439578b68074a5e2d80

View File

@@ -24,6 +24,7 @@ locales:
- vi
- pl-PL
- be-BY
- id-ID
files:
json:

View File

@@ -8,6 +8,7 @@
* - search_notes: full-text search across vault notes
* - get_vault_context: vault structure overview (types, note count, folders)
* - get_note: parsed frontmatter + content (convenience over raw cat)
* - create_note: create a new markdown note without overwriting existing files
* - open_note: signal Tolaria UI to open a note as a tab
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
* - refresh_vault: trigger vault rescan so new/modified files appear
@@ -19,10 +20,7 @@ import {
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import { searchNotes, getNote } from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
import { createMcpToolService } from './tool-service.js'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
@@ -32,6 +30,12 @@ const LOCAL_READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({
idempotentHint: true,
openWorldHint: false,
})
const LOCAL_CREATE_TOOL_ANNOTATIONS = Object.freeze({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
})
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
// The bridge relays messages to all other clients (the React frontend).
@@ -40,10 +44,6 @@ let reconnectTimer = null
let shutdownStarted = false
const RECONNECT_INTERVAL_MS = 3000
function activeVaultPaths() {
return requireVaultPaths()
}
function connectUiBridge() {
if (shutdownStarted) return
@@ -109,6 +109,8 @@ function broadcastUiAction(action, payload) {
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
}
const toolService = createMcpToolService({ emitUiAction: broadcastUiAction })
const TOOLS = [
{
name: 'search_notes',
@@ -156,6 +158,23 @@ const TOOLS = [
required: ['path'],
},
},
{
name: 'create_note',
description: 'Create a new markdown note inside an active Tolaria vault. Does not overwrite existing files. Use content for the full markdown including YAML frontmatter and H1.',
annotations: LOCAL_CREATE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path inside the vault, or an absolute path inside an active vault. Must end in .md.' },
content: { type: 'string', description: 'Full markdown note content, including YAML frontmatter when needed.' },
title: { type: 'string', description: 'Optional title used only when content is omitted.' },
type: { type: 'string', description: 'Optional note type used only when content is omitted.' },
is_a: { type: 'string', description: 'Legacy alias for type, used only when content is omitted.' },
vaultPath: { type: 'string', description: 'Optional target vault root when multiple vaults are active.' },
},
required: ['path'],
},
},
{
name: 'open_note',
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
@@ -196,80 +215,8 @@ const TOOLS = [
},
]
function requestedVaultPath(args = {}) {
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
if (!requested) return null
if (!activeVaultPaths().includes(requested)) {
throw new Error(`Vault is not active in Tolaria: ${requested}`)
}
return requested
}
function vaultLabel(vaultPath) {
return path.basename(vaultPath) || vaultPath
}
function withVaultMetadata(note, vaultPath) {
return {
...note,
vaultPath,
vaultLabel: vaultLabel(vaultPath),
}
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
const errors = []
for (const candidate of candidates) {
try {
matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate))
} catch (error) {
errors.push(error)
}
}
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
throw errors[0] ?? new Error(`Note not found: ${notePath}`)
}
async function searchActiveVaults(query, limit = 10) {
const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10
const results = []
for (const vaultPath of activeVaultPaths()) {
const vaultResults = await searchNotes(vaultPath, query, requestedLimit)
results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath)))
if (results.length >= requestedLimit) break
}
return results.slice(0, requestedLimit)
}
async function activeVaultContext(targetVaultPath = null) {
const roots = activeVaultPaths()
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return {
vaults: await Promise.all(roots.map(vaultContextWithInstructions)),
}
}
function uiPath(args = {}) {
const notePath = typeof args.path === 'string' ? args.path : ''
if (path.isAbsolute(notePath)) return notePath
const roots = activeVaultPaths()
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
async function handleSearchNotes(args) {
const results = await searchActiveVaults(args.query, args.limit)
const results = await toolService.searchNotes(args)
const text = results.length === 0
? 'No matching notes found.'
: results.map(r => `**${r.title}** (${r.vaultLabel} / ${r.path})\n${r.snippet}`).join('\n\n')
@@ -277,45 +224,43 @@ async function handleSearchNotes(args) {
}
async function handleVaultContext(args = {}) {
const ctx = await activeVaultContext(requestedVaultPath(args))
const ctx = await toolService.vaultContext(args)
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
}
async function handleListVaults() {
const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => {
const agentInstructions = await readAgentInstructions(vaultPath)
return {
path: vaultPath,
label: vaultLabel(vaultPath),
agentInstructionsPath: agentInstructions?.path ?? null,
hasAgentInstructions: agentInstructions !== null,
}
}))
return { content: [{ type: 'text', text: JSON.stringify({ vaults }, null, 2) }] }
return { content: [{ type: 'text', text: JSON.stringify(await toolService.listVaults(), null, 2) }] }
}
async function handleGetNote(args) {
const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args))
const note = await toolService.readNote(args)
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
}
async function handleCreateNote(args = {}) {
const note = await toolService.createNote(args)
return {
content: [{
type: 'text',
text: JSON.stringify(note, null, 2),
}],
}
}
function handleOpenNote(args) {
// Refresh vault first so the new/modified note appears in the note list,
// then signal the UI to open it in a tab.
const targetPath = uiPath(args)
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
const { targetPath } = toolService.openNoteAsTab(args)
return { content: [{ type: 'text', text: `Opening ${targetPath} in Tolaria` }] }
}
function handleHighlightEditor(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
toolService.highlightEditor(args)
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleRefreshVault(args) {
broadcastUiAction('vault_changed', { path: uiPath(args) })
toolService.refreshVault(args)
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
}
@@ -324,6 +269,7 @@ const TOOL_HANDLERS = new Map([
['get_vault_context', handleVaultContext],
['list_vaults', handleListVaults],
['get_note', handleGetNote],
['create_note', handleCreateNote],
['open_note', handleOpenNote],
['highlight_editor', handleHighlightEditor],
['refresh_vault', handleRefreshVault],

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"ws": "^8.19.0"
"ws": "^8.20.1"
}
},
"node_modules/@hono/node-server": {
@@ -619,9 +619,9 @@
}
},
"node_modules/hono": {
"version": "4.12.18",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
"version": "4.12.21",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.21.tgz",
"integrity": "sha512-uV63apnb0kyPtAUwoWgaGh9HyIFcv8lgmzPZSiTBQAFOFGIzka5EZ1dZocmGnn0XdX0+XTqJ6Tqv7selMuGLRQ==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -914,9 +914,9 @@
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -1227,9 +1227,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -6,18 +6,19 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node --test test.js"
"test": "node --test test.js tool-service.test.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"ws": "^8.19.0"
"ws": "^8.20.1"
},
"overrides": {
"@hono/node-server": "1.19.13",
"express-rate-limit": "8.2.2",
"fast-uri": "3.1.2",
"hono": "4.12.18",
"hono": "4.12.21",
"qs": "6.15.2",
"ip-address": "10.1.1",
"path-to-regexp": "8.4.0"
}

View File

@@ -2,7 +2,7 @@ import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import {
mkdtemp, mkdir, open, rm, writeFile,
access, mkdtemp, mkdir, open, readFile, rm, writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
createNote, findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { requireVaultPath, requireVaultPaths } from './vault-path.js'
import { vaultContextWithInstructions } from './agent-instructions.js'
@@ -126,6 +126,74 @@ describe('getNote', () => {
})
})
describe('createNote', () => {
it('creates a new markdown note inside the vault', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-'))
const content = `---
type: Note
---
# MCP Created
`
try {
const note = await createNote(vaultDir, 'note/mcp-created.md', content)
assert.equal(note.path, 'note/mcp-created.md')
assert.equal(await readFile(path.join(vaultDir, note.path), 'utf-8'), content)
} finally {
await rm(vaultDir, { recursive: true, force: true })
}
})
it('does not overwrite an existing note', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-existing-'))
const notePath = path.join(vaultDir, 'existing.md')
await writeFile(notePath, '# Existing\n', 'utf-8')
try {
await assert.rejects(
() => createNote(vaultDir, 'existing.md', '# Replacement\n'),
{ code: 'EEXIST' },
)
assert.equal(await readFile(notePath, 'utf-8'), '# Existing\n')
} finally {
await rm(vaultDir, { recursive: true, force: true })
}
})
it('rejects absolute paths outside the vault', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-'))
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-'))
try {
await assert.rejects(
() => createNote(vaultDir, path.join(outsideDir, 'outside.md'), '# Outside\n'),
{ message: ACTIVE_VAULT_ERROR },
)
} finally {
await rm(vaultDir, { recursive: true, force: true })
await rm(outsideDir, { recursive: true, force: true })
}
})
it('rejects outside paths before creating missing parent folders', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-'))
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-'))
const outsideParent = path.join(outsideDir, 'missing-parent')
try {
await assert.rejects(
() => createNote(vaultDir, path.join(outsideParent, 'outside.md'), '# Outside\n'),
{ message: ACTIVE_VAULT_ERROR },
)
await assert.rejects(() => access(outsideParent), { code: 'ENOENT' })
} finally {
await rm(vaultDir, { recursive: true, force: true })
await rm(outsideDir, { recursive: true, force: true })
}
})
})
describe('searchNotes', () => {
it('should find notes matching title', async () => {
const results = await searchNotes(tmpDir, 'Test Project')
@@ -330,11 +398,43 @@ describe('stdio process lifecycle', () => {
assert.equal(tool.annotations?.destructiveHint, false, `${name} should not be treated as destructive`)
assert.equal(tool.annotations?.openWorldHint, false, `${name} should stay scoped to local active vaults`)
}
const createTool = toolsByName.get('create_note')
assert.ok(createTool, 'Missing MCP tool: create_note')
assert.equal(createTool.annotations?.readOnlyHint, false)
assert.equal(createTool.annotations?.destructiveHint, false)
assert.equal(createTool.annotations?.openWorldHint, false)
} finally {
await closeMcpClient(client, stderr)
}
})
it('creates a note through the MCP create_note tool', async () => {
const { client, stderr } = await connectMcpClient()
const relativePath = 'note/mcp-tool-created.md'
const absolutePath = path.join(tmpDir, relativePath)
const content = `---
type: Note
---
# MCP Tool Created
`
try {
await rm(absolutePath, { force: true })
const result = await client.callTool({
name: 'create_note',
arguments: { path: relativePath, content },
})
assert.equal(await readFile(absolutePath, 'utf-8'), content)
assert.match(JSON.stringify(result.content), /mcp-tool-created\.md/)
} finally {
await rm(absolutePath, { force: true })
await closeMcpClient(client, stderr)
}
})
it('exits when the MCP client closes stdin', async () => {
const child = spawn(process.execPath, ['index.js'], {
cwd: MCP_SERVER_DIR,

213
mcp-server/tool-service.js Normal file
View File

@@ -0,0 +1,213 @@
import path from 'node:path'
import {
createNote as createVaultNote,
getNote,
searchNotes as searchVaultNotes,
} from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
export function createMcpToolService({
resolveVaultPaths = () => requireVaultPaths(),
emitUiAction = () => {},
} = {}) {
function activeVaultPaths() {
return resolveVaultPaths()
}
function requestedVaultPath(args = {}) {
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
if (!requested) return null
if (!activeVaultPaths().includes(requested)) {
throw new Error(`Vault is not active in Tolaria: ${requested}`)
}
return requested
}
function resolveUiPath(args = {}) {
const notePath = typeof args.path === 'string' ? args.path : ''
if (path.isAbsolute(notePath)) return notePath
const roots = activeVaultPaths()
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
async function readNote(args = {}) {
return getNoteFromActiveVaults(notePathArg(args), requestedVaultPath(args))
}
async function searchNotes(args = {}) {
const requestedLimit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 10
const results = []
for (const vaultPath of activeVaultPaths()) {
const vaultResults = await searchVaultNotes(vaultPath, args.query, requestedLimit)
results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath)))
if (results.length >= requestedLimit) break
}
return results.slice(0, requestedLimit)
}
async function vaultContext(args = {}) {
const targetVaultPath = requestedVaultPath(args)
const roots = activeVaultPaths()
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return {
vaults: await Promise.all(roots.map(vaultContextWithInstructions)),
}
}
async function listVaults() {
const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => {
const agentInstructions = await readAgentInstructions(vaultPath)
return {
path: vaultPath,
label: vaultLabel(vaultPath),
agentInstructionsPath: agentInstructions?.path ?? null,
hasAgentInstructions: agentInstructions !== null,
}
}))
return { vaults }
}
async function createNote(args = {}) {
const vaultPath = writableVaultPath(args)
const notePath = writableNotePath(args, vaultPath)
const note = await createVaultNote(vaultPath, notePath, createNoteContent(args))
const targetPath = resolveUiPath({ ...args, path: note.path, vaultPath })
emitUiAction('vault_changed', { path: targetPath })
emitUiAction('open_tab', { path: targetPath })
return { path: note.path, absolutePath: note.absolutePath, vaultPath }
}
function openNoteAsTab(args = {}) {
const targetPath = resolveUiPath(args)
emitUiAction('vault_changed', { path: targetPath })
emitUiAction('open_tab', { path: targetPath })
return { targetPath }
}
function openNoteInEditor(args = {}) {
const targetPath = resolveUiPath(args)
emitUiAction('vault_changed', { path: targetPath })
emitUiAction('open_note', { path: targetPath })
return { targetPath }
}
function highlightEditor(args = {}) {
emitUiAction('highlight', { element: args.element, path: args.path })
}
function setFilter(args = {}) {
emitUiAction('set_filter', { filterType: args.type })
}
function refreshVault(args = {}) {
emitUiAction('vault_changed', { path: resolveUiPath(args) })
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
const errors = []
for (const candidate of candidates) {
try {
matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate))
} catch (error) {
errors.push(error)
}
}
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
throw errors[0] ?? new Error(`Note not found: ${notePath}`)
}
function writableVaultPath(args = {}) {
const requested = requestedVaultPath(args)
if (requested) return requested
const roots = activeVaultPaths()
const notePath = notePathArg(args)
if (path.isAbsolute(notePath)) {
const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath))
if (root) return root
}
if (roots.length === 1) return roots[0]
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
return {
activeVaultPaths,
createNote,
highlightEditor,
listVaults,
openNoteAsTab,
openNoteInEditor,
readNote,
refreshVault,
requestedVaultPath,
resolveUiPath,
searchNotes,
setFilter,
vaultContext,
}
}
function writableNotePath(args, vaultPath) {
const notePath = notePathArg(args)
if (!path.isAbsolute(notePath) || !isInsideVaultRoot(vaultPath, notePath)) return notePath
return path.relative(vaultPath, notePath)
}
function withVaultMetadata(note, vaultPath) {
return {
...note,
vaultPath,
vaultLabel: vaultLabel(vaultPath),
}
}
function vaultLabel(vaultPath) {
return path.basename(vaultPath) || vaultPath
}
function isInsideVaultRoot(vaultPath, notePath) {
const relative = path.relative(vaultPath, notePath)
return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative)
}
function notePathArg(args = {}) {
const notePath = typeof args.path === 'string' ? args.path.trim() : ''
if (!notePath) throw new Error('Note path is required')
return notePath
}
function yamlScalar(value) {
return JSON.stringify(value)
}
function fallbackCreateNoteContent(args = {}) {
const title = typeof args.title === 'string' && args.title.trim()
? args.title.trim()
: path.basename(notePathArg(args), '.md')
const type = typeof args.type === 'string' && args.type.trim()
? args.type.trim()
: typeof args.is_a === 'string' && args.is_a.trim()
? args.is_a.trim()
: 'Note'
return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n`
}
function createNoteContent(args = {}) {
return typeof args.content === 'string' && args.content.trim()
? args.content
: fallbackCreateNoteContent(args)
}

View File

@@ -0,0 +1,156 @@
import { describe, it, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { createMcpToolService } from './tool-service.js'
let tmpDir
let firstVault
let secondVault
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-service-'))
firstVault = path.join(tmpDir, 'First Vault')
secondVault = path.join(tmpDir, 'Second Vault')
await seedVault(firstVault, {
'note/shared.md': noteFixture('Shared Note', 'Shared content from the first vault.'),
'note/alpha.md': noteFixture('Alpha Project', 'Project planning in the first vault.'),
})
await seedVault(secondVault, {
'AGENTS.md': '# Second Vault Rules\n',
'note/shared.md': noteFixture('Shared Note', 'Shared content from the second vault.'),
'note/beta.md': noteFixture('Beta Project', 'Project planning in the second vault.'),
})
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
describe('createMcpToolService', () => {
it('requires vaultPath when reading an ambiguous note path', async () => {
const service = makeService()
await assert.rejects(
() => service.readNote({ path: 'note/shared.md' }),
/Note path is ambiguous across active vaults/,
)
const note = await service.readNote({
path: 'note/shared.md',
vaultPath: secondVault,
})
assert.equal(note.vaultPath, secondVault)
assert.equal(note.vaultLabel, 'Second Vault')
assert.match(note.content, /second vault/)
})
it('creates notes with fallback markdown and emits refresh and tab actions', async () => {
const emittedActions = []
const service = makeService({ emittedActions })
const absolutePath = path.join(secondVault, 'note/created.md')
const note = await service.createNote({
path: absolutePath,
title: 'Created From MCP',
type: 'Project',
})
assert.equal(note.path, 'note/created.md')
assert.equal(note.vaultPath, secondVault)
assert.equal(path.basename(note.absolutePath), 'created.md')
assert.equal(
await readFile(note.absolutePath, 'utf-8'),
'---\ntype: "Project"\n---\n\n# Created From MCP\n',
)
assert.deepEqual(emittedActions, [
{ action: 'vault_changed', payload: { path: absolutePath } },
{ action: 'open_tab', payload: { path: absolutePath } },
])
})
it('searches active vaults with consistent vault metadata', async () => {
const service = makeService()
const results = await service.searchNotes({ query: 'Project', limit: 2 })
assert.equal(results.length, 2)
assert.deepEqual(
results.map(({ path: notePath, vaultPath, vaultLabel }) => ({
notePath,
vaultPath,
vaultLabel,
})),
[
{ notePath: 'note/alpha.md', vaultPath: firstVault, vaultLabel: 'First Vault' },
{ notePath: 'note/beta.md', vaultPath: secondVault, vaultLabel: 'Second Vault' },
],
)
})
it('lists active vaults with agent-instruction metadata', async () => {
const service = makeService()
assert.deepEqual(await service.listVaults(), {
vaults: [
{
path: firstVault,
label: 'First Vault',
agentInstructionsPath: null,
hasAgentInstructions: false,
},
{
path: secondVault,
label: 'Second Vault',
agentInstructionsPath: path.join(secondVault, 'AGENTS.md'),
hasAgentInstructions: true,
},
],
})
})
it('emits transport-neutral UI intents for note opening and filters', () => {
const emittedActions = []
const service = makeService({ emittedActions })
service.openNoteAsTab({ path: 'note/beta.md', vaultPath: secondVault })
service.openNoteInEditor({ path: 'note/beta.md', vaultPath: secondVault })
service.highlightEditor({ element: 'editor', path: 'note/beta.md' })
service.setFilter({ type: 'Project' })
service.refreshVault({ path: 'note/beta.md', vaultPath: secondVault })
assert.deepEqual(emittedActions, [
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'open_tab', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'open_note', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'highlight', payload: { element: 'editor', path: 'note/beta.md' } },
{ action: 'set_filter', payload: { filterType: 'Project' } },
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
])
})
})
function makeService({ emittedActions = [] } = {}) {
return createMcpToolService({
resolveVaultPaths: () => [firstVault, secondVault],
emitUiAction: (action, payload) => {
emittedActions.push({ action, payload })
},
})
}
async function seedVault(vaultPath, files) {
for (const [relativePath, content] of Object.entries(files)) {
const filePath = path.join(vaultPath, relativePath)
await mkdir(path.dirname(filePath), { recursive: true })
await writeFile(filePath, content, 'utf-8')
}
}
function noteFixture(title, body) {
return `---\ntitle: ${JSON.stringify(title)}\ntype: Note\n---\n\n# ${title}\n\n${body}\n`
}

View File

@@ -1,9 +1,10 @@
/**
* Vault operations — read-only helpers for Tolaria markdown vault.
* Write operations are handled by the app-managed agent's active permission
* profile and native file-edit tools when available.
* Most write operations are handled by the app-managed agent's active
* permission profile and native file-edit tools; createNote is intentionally
* narrow so read-only agents can create a new Markdown file without overwrite.
*/
import { open, opendir, realpath } from 'node:fs/promises'
import { mkdir, open, opendir, realpath } from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
@@ -60,6 +61,22 @@ export async function getNote(vaultPath, notePath) {
}
}
/**
* Create a new markdown note inside the vault without overwriting an existing file.
* @param {string} vaultPath
* @param {string} notePath
* @param {string} content
* @returns {Promise<{path: string, absolutePath: string}>}
*/
export async function createNote(vaultPath, notePath, content) {
const { requestedPath, relativePath } = await resolveNewVaultNotePath(vaultPath, notePath)
await writeNewUtf8File(requestedPath, content)
return {
path: relativePath,
absolutePath: requestedPath,
}
}
/**
* Search notes by title or content substring.
* @param {string} vaultPath
@@ -145,6 +162,61 @@ function resolveRequestedNotePath(vaultRoot, notePath) {
return resolved
}
async function resolveNewVaultNotePath(vaultPath, notePath) {
const requestedNotePath = validateNewNotePath(notePath)
const vaultRoot = await realpath(vaultPath)
const requestedPath = resolveRequestedNotePath(vaultRoot, requestedNotePath)
const relativePath = relativeNotePathInsideVault(vaultRoot, requestedPath)
await ensureWritableParentInsideVault(vaultRoot, requestedPath)
return { requestedPath, relativePath }
}
function validateNewNotePath(notePath) {
const trimmedPath = typeof notePath === 'string' ? notePath.trim() : ''
if (!trimmedPath) {
throw new Error('Note path is required')
}
if (!trimmedPath.endsWith('.md')) {
throw new Error('New notes must be markdown files ending in .md')
}
return trimmedPath
}
async function ensureWritableParentInsideVault(vaultRoot, requestedPath) {
const parentPath = path.dirname(requestedPath)
const existingAncestor = await nearestExistingAncestor(parentPath)
assertInsideVault(vaultRoot, existingAncestor)
await mkdir(parentPath, { recursive: true })
assertInsideVault(vaultRoot, await realpath(parentPath))
}
async function nearestExistingAncestor(targetPath) {
let currentPath = targetPath
while (currentPath && currentPath !== path.dirname(currentPath)) {
try {
return await realpath(currentPath)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
currentPath = path.dirname(currentPath)
}
}
return realpath(currentPath)
}
function assertInsideVault(vaultRoot, targetPath) {
if (!isVaultRelativePath(path.relative(vaultRoot, targetPath))) {
throw new Error(ACTIVE_VAULT_ERROR)
}
}
function relativeNotePathInsideVault(vaultRoot, requestedPath) {
const relativePath = path.relative(vaultRoot, requestedPath)
if (!isVaultRelativePath(relativePath) || !relativePath) {
throw new Error(ACTIVE_VAULT_ERROR)
}
return relativePath
}
function resolveInside(root, target) {
const resolved = path.resolve(root, target)
const relative = path.relative(root, resolved)
@@ -338,6 +410,15 @@ async function readUtf8File(filePath) {
}
}
async function writeNewUtf8File(filePath, content) {
const handle = await open(filePath, 'wx')
try {
await handle.writeFile(content, 'utf-8')
} finally {
await handle.close()
}
}
async function statFile(filePath) {
const handle = await open(filePath, 'r')
try {

View File

@@ -21,12 +21,7 @@
*/
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import {
getNote, searchNotes,
} from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
import { createMcpToolService } from './tool-service.js'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
@@ -41,66 +36,6 @@ const TRUSTED_UI_ORIGINS = new Set([
let uiBridge = null
const UNKNOWN_TOOL = Symbol('unknown tool')
function activeVaultPaths() {
return requireVaultPaths()
}
function requestedVaultPath(args = {}) {
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
if (!requested) return null
if (!activeVaultPaths().includes(requested)) {
throw new Error(`Vault is not active in Tolaria: ${requested}`)
}
return requested
}
function uiPath(args = {}) {
const notePath = typeof args.path === 'string' ? args.path : ''
if (path.isAbsolute(notePath)) return notePath
const roots = activeVaultPaths()
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
const errors = []
for (const candidate of candidates) {
try {
matches.push({ ...(await getNote(candidate, notePath)), vaultPath: candidate })
} catch (error) {
errors.push(error)
}
}
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
throw errors[0] ?? new Error(`Note not found: ${notePath}`)
}
async function searchActiveVaults(query, limit = 10) {
const requestedLimit = Number.isFinite(limit) && limit > 0 ? limit : 10
const results = []
for (const vaultPath of activeVaultPaths()) {
const vaultResults = await searchNotes(vaultPath, query, requestedLimit)
results.push(...vaultResults.map((result) => ({ ...result, vaultPath })))
if (results.length >= requestedLimit) break
}
return results.slice(0, requestedLimit)
}
async function activeVaultContext() {
const roots = activeVaultPaths()
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return { vaults: await Promise.all(roots.map(vaultContextWithInstructions)) }
}
function broadcastUiAction(action, payload) {
if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
@@ -109,61 +44,49 @@ function broadcastUiAction(action, payload) {
}
}
const toolService = createMcpToolService({ emitUiAction: broadcastUiAction })
async function readNoteTool(args) {
const note = await getNoteFromActiveVaults(args.path, requestedVaultPath(args))
const note = await toolService.readNote(args)
return { content: note.content, frontmatter: note.frontmatter }
}
function uiOpenNoteTool(args) {
const targetPath = uiPath(args)
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_note', { path: targetPath })
toolService.openNoteInEditor(args)
return { ok: true }
}
function uiOpenTabTool(args) {
const targetPath = uiPath(args)
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
toolService.openNoteAsTab(args)
return { ok: true }
}
async function createNoteTool(args = {}) {
return { ok: true, ...(await toolService.createNote(args)) }
}
function highlightTool(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
toolService.highlightEditor(args)
return { ok: true }
}
function uiSetFilterTool(args) {
broadcastUiAction('set_filter', { filterType: args.type })
toolService.setFilter(args)
return { ok: true }
}
function refreshVaultTool(args) {
broadcastUiAction('vault_changed', { path: uiPath(args) })
toolService.refreshVault(args)
return { ok: true }
}
async function listVaultsTool() {
return {
vaults: await Promise.all(activeVaultPaths().map(async (vaultPath) => {
const agentInstructions = await readAgentInstructions(vaultPath)
return {
path: vaultPath,
label: path.basename(vaultPath) || vaultPath,
agentInstructionsPath: agentInstructions?.path ?? null,
hasAgentInstructions: agentInstructions !== null,
}
})),
}
}
const TOOL_EXECUTORS = [
['open_note', readNoteTool],
['read_note', readNoteTool],
['search_notes', (args) => searchActiveVaults(args.query, args.limit)],
['vault_context', () => activeVaultContext()],
['list_vaults', () => listVaultsTool()],
['create_note', createNoteTool],
['search_notes', (args) => toolService.searchNotes(args)],
['vault_context', (args) => toolService.vaultContext(args)],
['list_vaults', () => toolService.listVaults()],
['ui_open_note', uiOpenNoteTool],
['ui_open_tab', uiOpenTabTool],
['ui_highlight', highlightTool],
@@ -279,7 +202,7 @@ export function startUiBridge(port = WS_UI_PORT) {
}
export function startBridge(port = WS_PORT) {
const currentVaultPaths = activeVaultPaths()
const currentVaultPaths = toolService.activeVaultPaths()
const wss = new WebSocketServer({
port,
host: LOOPBACK_HOST,
@@ -309,7 +232,7 @@ export function startBridge(port = WS_PORT) {
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
try {
activeVaultPaths()
toolService.activeVaultPaths()
startUiBridge().then(() => startBridge())
} catch (err) {
console.error(`[ws-bridge] ${err.message}`)

View File

@@ -53,8 +53,10 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/react": "^10.47.0",
"@shikijs/langs": "3.23.0",
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-deep-link": "2.4.9",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",

36
pnpm-lock.yaml generated
View File

@@ -122,12 +122,18 @@ importers:
'@sentry/react':
specifier: ^10.47.0
version: 10.47.0(react@19.2.4)
'@shikijs/langs':
specifier: 3.23.0
version: 3.23.0
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.3.2(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.3))
'@tauri-apps/api':
specifier: ^2.10.1
version: 2.10.1
'@tauri-apps/plugin-deep-link':
specifier: 2.4.9
version: 2.4.9
'@tauri-apps/plugin-dialog':
specifier: ^2.6.0
version: 2.6.0
@@ -295,8 +301,8 @@ importers:
specifier: ^4.0.3
version: 4.0.3
ws:
specifier: ^8.19.0
version: 8.19.0
specifier: ^8.20.1
version: 8.21.0
packages:
@@ -2457,6 +2463,9 @@ packages:
'@tauri-apps/api@2.10.1':
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
'@tauri-apps/api@2.11.0':
resolution: {integrity: sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==}
'@tauri-apps/cli-darwin-arm64@2.10.0':
resolution: {integrity: sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==}
engines: {node: '>= 10'}
@@ -2533,6 +2542,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-deep-link@2.4.9':
resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==}
'@tauri-apps/plugin-dialog@2.6.0':
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
@@ -5886,6 +5898,18 @@ packages:
utf-8-validate:
optional: true
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -8164,6 +8188,8 @@ snapshots:
'@tauri-apps/api@2.10.1': {}
'@tauri-apps/api@2.11.0': {}
'@tauri-apps/cli-darwin-arm64@2.10.0':
optional: true
@@ -8211,6 +8237,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.10.0
'@tauri-apps/cli-win32-x64-msvc': 2.10.0
'@tauri-apps/plugin-deep-link@2.4.9':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-dialog@2.6.0':
dependencies:
'@tauri-apps/api': 2.10.1
@@ -12217,6 +12247,8 @@ snapshots:
ws@8.19.0: {}
ws@8.21.0: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}

View File

@@ -0,0 +1,8 @@
# AI Agent Icons
- Claude Code: Claude mark from the official Claude wordmark on https://claude.com/product/claude-code
- Codex: OpenAI mark from https://openai.com/favicon.svg
- OpenCode: official OpenCode brand asset from https://opencode.ai/brand
- Pi: official Pi favicon from https://hey.pi.ai
- Gemini CLI: Google-hosted Gemini sparkle from https://www.gstatic.com/lamda/images/gemini_sparkle_v002_d4735304ff6292a690345.svg
- Kiro: official Kiro icon from https://kiro.dev/icon.svg?fe599162bb293ea0

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 125 125" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M54.375 118.75L56.125 111L58.125 101L59.75 93L61.25 83.125L62.125 79.875L62 79.625L61.375 79.75L53.875 90L42.5 105.375L33.5 114.875L31.375 115.75L27.625 113.875L28 110.375L30.125 107.375L42.5 91.5L50 81.625L54.875 76L54.75 75.25H54.5L21.5 96.75L15.625 97.5L13 95.125L13.375 91.25L14.625 90L24.5 83.125L49.125 69.375L49.5 68.125L49.125 67.5H47.875L43.75 67.25L29.75 66.875L17.625 66.375L5.75 65.75L2.75 65.125L0 61.375L0.25 59.5L2.75 57.875L6.375 58.125L14.25 58.75L26.125 59.5L34.75 60L47.5 61.375H49.5L49.75 60.5L49.125 60L48.625 59.5L36.25 51.25L23 42.5L16 37.375L12.25 34.75L10.375 32.375L9.625 27.125L13 23.375L17.625 23.75L18.75 24L23.375 27.625L33.25 35.25L46.25 44.875L48.125 46.375L49 45.875V45.5L48.125 44.125L41.125 31.375L33.625 18.375L30.25 13L29.375 9.75C29.0417 8.625 28.875 7.375 28.875 6L32.75 0.750006L34.875 0L40.125 0.750006L42.25 2.625L45.5 10L50.625 21.625L58.75 37.375L61.125 42.125L62.375 46.375L62.875 47.75H63.75V47L64.375 38L65.625 27.125L66.875 13.125L67.25 9.125L69.25 4.375L73.125 1.87501L76.125 3.25L78.625 6.875L78.25 9.125L76.875 18.75L73.875 33.875L72 44.125H73.125L74.375 42.75L79.5 36L88.125 25.25L91.875 21L96.375 16.25L99.25 14H104.625L108.5 19.875L106.75 26L101.25 33L96.625 38.875L90 47.75L86 54.875L86.375 55.375H87.25L102.125 52.125L110.25 50.75L119.75 49.125L124.125 51.125L124.625 53.125L122.875 57.375L112.625 59.875L100.625 62.25L82.75 66.5L82.5 66.625L82.75 67L90.75 67.75L94.25 68H102.75L118.5 69.125L122.625 71.875L125 75.125L124.625 77.75L118.25 80.875L109.75 78.875L89.75 74.125L83 72.5H82V73L87.75 78.625L98.125 88L111.25 100.125L111.875 103.125L110.25 105.625L108.5 105.375L97 96.625L92.5 92.75L82.5 84.375H81.875V85.25L84.125 88.625L96.375 107L97 112.625L96.125 114.375L92.875 115.5L89.5 114.875L82.25 104.875L74.875 93.5L68.875 83.375L68.25 83.875L64.625 121.625L63 123.5L59.25 125L56.125 122.625L54.375 118.75Z" fill="#D97757" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg width="41" height="41" viewBox="0 0 41 41" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z" fill="#111111" />
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -0,0 +1,10 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 28C14 26.0633 13.6267 24.2433 12.88 22.54C12.1567 20.8367 11.165 19.355 9.905 18.095C8.645 16.835 7.16333 15.8433 5.46 15.12C3.75667 14.3733 1.93667 14 0 14C1.93667 14 3.75667 13.6383 5.46 12.915C7.16333 12.1683 8.645 11.165 9.905 9.905C11.165 8.645 12.1567 7.16333 12.88 5.46C13.6267 3.75667 14 1.93667 14 0C14 1.93667 14.3617 3.75667 15.085 5.46C15.8317 7.16333 16.835 8.645 18.095 9.905C19.355 11.165 20.8367 12.1683 22.54 12.915C24.2433 13.6383 26.0633 14 28 14C26.0633 14 24.2433 14.3733 22.54 15.12C20.8367 15.8433 19.355 16.835 18.095 18.095C16.835 19.355 15.8317 20.8367 15.085 22.54C14.3617 24.2433 14 26.0633 14 28Z" fill="url(#paint0_radial_16771_53212)" />
<defs>
<radialGradient id="paint0_radial_16771_53212" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2.77876 11.3795) rotate(18.6832) scale(29.8025 238.737)">
<stop offset="0.0671246" stop-color="#9168C0" />
<stop offset="0.342551" stop-color="#5684D1" />
<stop offset="0.672076" stop-color="#1BA1E3" />
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,11 @@
<svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="1200" rx="260" fill="#9046FF" />
<mask id="mask0_1106_4856" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="272" y="202" width="655" height="796">
<path d="M926.578 202.793H272.637V997.857H926.578V202.793Z" fill="white" />
</mask>
<g mask="url(#mask0_1106_4856)">
<path d="M398.554 818.914C316.315 1001.03 491.477 1046.74 620.672 940.156C658.687 1059.66 801.052 970.473 852.234 877.795C964.787 673.567 919.318 465.357 907.64 422.374C827.637 129.443 427.623 128.946 358.8 423.865C342.651 475.544 342.402 534.18 333.458 595.051C328.986 625.86 325.507 645.488 313.83 677.785C306.873 696.424 297.68 712.819 282.773 740.645C259.915 783.881 269.604 867.113 387.87 823.883L399.051 818.914H398.554Z" fill="white" />
<path d="M636.123 549.353C603.328 549.353 598.359 510.097 598.359 486.742C598.359 465.623 602.086 448.977 609.293 438.293C615.504 428.852 624.697 424.131 636.123 424.131C647.555 424.131 657.492 428.852 664.447 438.541C672.398 449.474 676.623 466.12 676.623 486.742C676.623 525.998 661.471 549.353 636.375 549.353H636.123Z" fill="black" />
<path d="M771.24 549.353C738.445 549.353 733.477 510.097 733.477 486.742C733.477 465.623 737.203 448.977 744.41 438.293C750.621 428.852 759.814 424.131 771.24 424.131C782.672 424.131 792.609 428.852 799.564 438.541C807.516 449.474 811.74 466.12 811.74 486.742C811.74 525.998 796.588 549.353 771.492 549.353H771.24Z" fill="black" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,18 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(30, 0)">
<g clip-path="url(#clip0_1401_86274)">
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
<path d="M240 0H0V300H240V0Z" fill="white" />
</mask>
<g mask="url(#mask0_1401_86274)">
<path d="M180 240H60V120H180V240Z" fill="#CFCECD" />
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E" />
</g>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86274">
<rect width="240" height="300" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 713 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" fill="none">
<g clip-path="url(#a)">
<path fill="#FAF3EA" d="M-14-14h208v208H-14z" />
<path fill="#038247" d="M121.084 57.922c0 7.408 6.061 13.469 13.469 13.469 7.408 0 13.469-6.061 13.469-13.47 0-7.407-6.061-13.468-13.469-13.468-7.408 0-13.469 6.06-13.469 13.469Zm-5.657 81.622v-.135c0-2.828 1.617-4.579 5.388-5.792 3.771-1.347 4.714-2.289 4.714-7.273v-24.648c0-4.984-1.347-5.657-5.118-6.87-3.098-.942-4.984-2.424-4.984-4.713 0-1.886 1.213-3.368 2.963-4.041l19.531-7.004c4.31-1.616 7.677.539 7.677 4.445v42.831c0 4.984.943 5.926 4.714 7.273 3.771 1.213 5.388 2.964 5.388 5.792v.135c0 2.694-2.155 4.579-5.388 4.579h-29.497c-3.233 0-5.388-1.885-5.388-4.579ZM65.053 68.427c0-17.24 2.425-22.897 9.832-22.897 9.025 0 14.278 8.755 14.278 23.706 0 14.95-5.119 23.974-13.47 23.974-8.35 0-10.64-6.6-10.64-24.783ZM27.205 139.41v.135c0 2.694 2.155 4.579 5.388 4.579H76.77c3.233 0 5.388-1.885 5.388-4.579v-.135c0-2.559-1.482-4.445-5.253-5.253-9.428-2.155-10.64-3.098-10.64-8.351v-19.26c0-2.425 1.077-4.041 3.367-4.041s4.175 1.078 8.35 1.078c21.82 0 37.31-14.547 37.31-35.02 0-20.473-14.547-33.672-35.29-33.672-11.313 0-19.664 4.175-30.574 4.175H32.593c-3.233 0-5.388 1.886-5.388 4.58v.134c0 2.829 1.616 4.58 5.388 5.522 6.734 1.617 7.677 2.56 7.677 7.543v69.5c0 4.984-.943 5.926-7.677 7.543-3.772.943-5.388 2.694-5.388 5.522Z" />
</g>
<defs>
<clipPath id="a">
<rect width="180" height="180" fill="#fff" rx="45" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,21 @@
## New Features
- 🤖 **Side AI Workspace** — Work with AI in a dedicated side workspace that keeps conversations and context closer to the note you are editing.
- ↩️ **Action History Undo and Redo** — Note actions now participate in a clearer undo/redo history, making structural edits easier to reverse.
- 📁 **Create Notes Inside Folders** — New notes can be created directly inside the selected folder instead of always starting at the vault root.
- 🌏 **Indonesian Language Support** — Tolaria now includes an Indonesian translation alongside the existing supported languages.
## Improvements
- 🔎 **Search Note Body Content** — Note-list search can now find matches inside note bodies, not only titles and visible metadata.
- 🧭 **Vault Item Deep Links** — Links to specific vault items route more reliably across notes, folders, and workspace surfaces.
-**Faster AI Agent Startup** — Local AI agent discovery is deferred and parallelized so opening Tolaria does less blocking work up front.
- 🪟 **Refined AI Workspace Layout** — The AI workspace side panel, floating button, conversation titles, and shared sessions feel more stable and focused.
- 🔄 **More Reliable Git Remote Handling** — Git remote detection now treats local-only and name-only configurations more consistently.
## Stability and Fixes
- Editor saves, malformed nested blocks, table rendering, math source editing, KaTeX radicals, and whiteboard permission errors were hardened before promotion.
- Search keyboard navigation, quick-open note creation, favorite hover states, sort dropdown positioning, and full app note windows were stabilized.
- Release automation now reuses artifact build workflows more cleanly and handles Windows signing/authenticode paths more defensively.
- Packaged macOS MCP server paths, pending-change filenames with spaces, vault guidance edit status, and multi-vault autosync behavior were repaired.

View File

@@ -0,0 +1,17 @@
## New Features
- 📄 **Export Notes to PDF** — Export the active note directly to a PDF from Tolaria, with the release build carrying the native desktop export path.
- 🧩 **More Code Block Languages** — Rich-editor code blocks now recognize more common grammars, including shell, config, infrastructure, and scripting formats.
## Improvements
- 🧮 **Editable Math Source Panel** — Double-click rendered display formulas to edit the underlying math source without turning the formula into normal Markdown text.
- ✍️ **Cleaner Math Editing UX** — Selected math source text stays readable in the editor panel, and the focused panel now avoids the doubled active-border treatment.
- 🤖 **More Focused AI Workspace Internals** — The AI workspace keeps shared sessions and orchestration logic cleaner while preserving the existing side-panel behavior.
## Stability and Fixes
- Active notes refresh more reliably after external edits without disturbing clean in-editor state.
- Windows command-shim handling is steadier for npm-launched agent commands.
- PDF export, display-math editing, KaTeX source selection, AI workspace title persistence, and Tauri listener cleanup were hardened before promotion.
- Stable release signing and artifact workflows were tightened so the stable channel can publish the expected macOS, Windows, and Linux bundles.

View File

@@ -0,0 +1,18 @@
## New Features
- 📝 **AI Agents Can Create Notes Directly** — OpenAI-backed note creation now works inside Tolaria, making agent-driven capture and drafting feel more complete.
- 🧠 **Built-in MCP Note Creation** — Tolaria now exposes a native MCP tool for creating notes, improving automation workflows that operate on the vault.
- 🗺️ **Fullscreen Whiteboards** — Whiteboards can now open in a dedicated fullscreen workspace for more focused visual work.
## Improvements
-**Markdown Highlights Render Properly** — Highlight syntax now displays correctly in the editor, with matching toolbar copy and better visual consistency.
- 🌍 **Better Localization Coverage** — More menus, feedback text, and Chinese file-manager labels are now translated and aligned with the rest of the app.
- 🔧 **Shared MCP Orchestration Internals** — MCP tool execution now runs through a more unified internal path, reducing inconsistency across tools.
## Stability and Fixes
- Copying from the rich editor is more reliable, inline math sits correctly in text, Mermaid raw-edit access is restored, and table-row parsing is more resilient.
- Focus and refresh behavior were hardened across note editing, active-note refreshes, title focus, and view filters across multiple workspaces.
- Git identity handling, symlinked gitignored vault paths, sync history surfaces, drag-and-drop guards, Pi agent config recovery, and shell AI-provider inheritance all received targeted fixes.
- MCP and editor error recovery were tightened, including null editor transform handling, stale config tolerance, and lockfile synchronization.

View File

@@ -23,11 +23,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

132
src-tauri/Cargo.lock generated
View File

@@ -634,6 +634,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -723,6 +743,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -898,7 +924,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -953,6 +979,15 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1085,7 +1120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2984,6 +3019,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3501,7 +3546,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -3888,6 +3933,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.40.0"
@@ -3935,7 +3990,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3992,7 +4047,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4905,6 +4960,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
@@ -5013,6 +5089,22 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.18",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.0"
@@ -5157,7 +5249,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5244,6 +5336,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.2"
@@ -5339,6 +5440,10 @@ dependencies = [
"gray_matter",
"log",
"notify",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-web-kit",
"regex",
"reqwest 0.12.28",
"sentry",
@@ -5347,11 +5452,13 @@ dependencies = [
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-prevent-default",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tempfile",
"tokio",
@@ -6094,7 +6201,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6221,6 +6328,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.3.4"

View File

@@ -21,7 +21,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
log = "0.4"
notify = "6.1"
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools", "image-png"] }
tauri = { version = "2.10.0", features = ["macos-private-api", "protocol-asset", "devtools", "image-png"] }
tauri-plugin-log = "2"
gray_matter = "0.2"
walkdir = "2"
@@ -40,5 +40,13 @@ sentry = "0.37"
uuid = { version = "1", features = ["v4"] }
tempfile = "3"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
tauri-plugin-deep-link = "2.4.9"
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.3"
objc2-app-kit = "0.3.2"
objc2-foundation = "0.3.2"
objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "objc2-app-kit"] }
[dev-dependencies]

View File

@@ -18,7 +18,9 @@
"core:window:allow-close",
"core:window:allow-set-title",
"core:webview:allow-create-webview-window",
"core:event:default",
"dialog:default",
"deep-link:default",
"updater:default",
"process:default",
"opener:default"

View File

@@ -120,11 +120,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

View File

@@ -28,11 +28,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

View File

@@ -39,6 +39,7 @@
"Download",
"Homebrew",
"Platform Status",
"Managed Windows Devices",
"After Installing"
]
},

View File

@@ -111,11 +111,15 @@ brew install --cask tolaria
| Platform | Status | Notes |
| --- | --- | --- |
| macOS | Primary | Apple Silicon and Intel builds are published. Homebrew is available. |
| Windows | Supported, early | NSIS installers and signed updater bundles are published. Some shell and menu behavior can still need Windows-specific fixes. |
| Windows | Supported, early | NSIS installers are Authenticode-signed and updater bundles are Tauri-signed. Company-managed SmartScreen, Defender, or WDAC policies can still require IT approval of the Tolaria publisher before first install. |
| Linux | Supported, early | AppImage, deb, and RPM artifacts are published. Desktop behavior depends on distribution WebKitGTK and input-method integration. |
See [Supported Platforms](/reference/supported-platforms) for the current support policy.
## Managed Windows Devices
Do not disable SmartScreen or Windows Security to install Tolaria. On a managed Windows device, validate that the downloaded installer has a valid Tolaria Authenticode signature, then install it through the normal Windows prompt. If company policy still blocks the first run because reputation is not yet established, ask IT to approve the Tolaria publisher or deploy the same signed installer through the approved software portal.
## After Installing
1. Open Tolaria.

View File

@@ -1,4 +1,8 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::task::JoinHandle;
const AI_AGENT_STATUS_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -73,6 +77,8 @@ pub struct AiAgentStreamRequest {
#[serde(default)]
pub vault_paths: Vec<String>,
pub permission_mode: Option<AiAgentPermissionMode>,
#[serde(default)]
pub event_name: Option<String>,
}
impl AiAgentStreamRequest {
@@ -81,14 +87,60 @@ impl AiAgentStreamRequest {
}
}
pub fn get_ai_agents_status() -> AiAgentsStatus {
/// Probe every supported AI-agent CLI in parallel.
///
/// Each per-agent `check_cli()` is synchronous and can block for up to ~1 s
/// when the binary is missing and we fall through to the login-shell
/// fallback (`/bin/zsh -lc 'command -v <agent>'` etc., evaluating the full
/// shell startup). Running them sequentially used to add ~5 s to cold start
/// when no agents are installed. Fan them out across Tokio's blocking pool
/// so the user-perceived wall time is the slowest single probe rather than
/// the sum of all six.
///
/// A panicking probe is mapped to `installed: false` so the IPC handler
/// always returns a fully populated `AiAgentsStatus` and the frontend can
/// keep rendering.
pub async fn get_ai_agents_status() -> AiAgentsStatus {
let claude = tokio::task::spawn_blocking(availability_from_claude);
let codex = tokio::task::spawn_blocking(crate::codex_cli::check_cli);
let opencode = tokio::task::spawn_blocking(crate::opencode_cli::check_cli);
let pi = tokio::task::spawn_blocking(crate::pi_cli::check_cli);
let gemini = tokio::task::spawn_blocking(crate::gemini_cli::check_cli);
let kiro = tokio::task::spawn_blocking(crate::kiro_cli::check_cli);
let (claude, codex, opencode, pi, gemini, kiro) = tokio::join!(
availability_or_missing(claude, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(codex, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(opencode, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(pi, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(gemini, AI_AGENT_STATUS_PROBE_TIMEOUT),
availability_or_missing(kiro, AI_AGENT_STATUS_PROBE_TIMEOUT)
);
AiAgentsStatus {
claude_code: availability_from_claude(),
codex: crate::codex_cli::check_cli(),
opencode: crate::opencode_cli::check_cli(),
pi: crate::pi_cli::check_cli(),
gemini: crate::gemini_cli::check_cli(),
kiro: crate::kiro_cli::check_cli(),
claude_code: claude,
codex,
opencode,
pi,
gemini,
kiro,
}
}
async fn availability_or_missing(
probe: JoinHandle<AiAgentAvailability>,
timeout: Duration,
) -> AiAgentAvailability {
match tokio::time::timeout(timeout, probe).await {
Ok(Ok(availability)) => availability,
Ok(Err(_)) | Err(_) => missing_availability(),
}
}
fn missing_availability() -> AiAgentAvailability {
AiAgentAvailability {
installed: false,
version: None,
}
}
@@ -230,6 +282,7 @@ mod tests {
vault_path: "/tmp/vault".into(),
vault_paths: Vec::new(),
permission_mode,
event_name: None,
}
}
@@ -245,15 +298,16 @@ mod tests {
);
}
#[test]
fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status();
#[tokio::test]
async fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status().await;
let install_flags = [
status.claude_code.installed,
status.codex.installed,
status.opencode.installed,
status.pi.installed,
status.gemini.installed,
status.kiro.installed,
];
assert!(install_flags
@@ -261,6 +315,22 @@ mod tests {
.all(|installed| matches!(installed, true | false)));
}
#[tokio::test]
async fn availability_probe_timeout_returns_missing_status() {
let handle = tokio::task::spawn_blocking(|| {
std::thread::sleep(std::time::Duration::from_millis(50));
AiAgentAvailability {
installed: true,
version: Some("late".into()),
}
});
let status = availability_or_missing(handle, std::time::Duration::from_millis(1)).await;
assert!(!status.installed);
assert_eq!(status.version, None);
}
#[test]
fn map_claude_done_event_preserves_completion_signal() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done);

View File

@@ -0,0 +1,623 @@
use crate::ai_agents::AiAgentStreamEvent;
use crate::ai_models::{AiModelProviderKind, AiModelStreamRequest};
use std::path::{Path, PathBuf};
const CREATE_NOTE_TOOL_NAME: &str = "create_note";
const CREATE_NOTE_TOOL_JSON: &str = r#"{
"type": "function",
"function": {
"name": "create_note",
"description": "Create a new markdown note inside the active Tolaria vault without overwriting existing files.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Relative path inside the vault, or an absolute path inside the active vault. Must end in .md."
},
"content": {
"type": "string",
"description": "Full markdown note content, including YAML frontmatter and H1 when needed."
},
"title": {
"type": "string",
"description": "Optional title used only when content is omitted."
},
"type": {
"type": "string",
"description": "Optional note type used only when content is omitted."
},
"is_a": {
"type": "string",
"description": "Legacy alias for type, used only when content is omitted."
},
"vaultPath": {
"type": "string",
"description": "Optional target vault root when multiple vaults are active."
}
},
"required": ["path"],
"additionalProperties": false
}
}
}"#;
struct OpenAiToolCall {
id: String,
name: String,
arguments: serde_json::Value,
raw_arguments: String,
}
struct CreatedNoteToolResult {
summary: String,
output: String,
}
pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json::Value {
let mut payload = serde_json::json!({
"model": request.model_id,
"messages": openai_chat_messages(request),
"stream": false
});
if should_offer_openai_tools(request) {
payload["tools"] = serde_json::Value::Array(vec![openai_create_note_tool()]);
payload["tool_choice"] = serde_json::Value::String("auto".into());
}
payload
}
pub(crate) fn execute_openai_tool_calls<F>(
request: &AiModelStreamRequest,
json: &serde_json::Value,
emit: F,
) -> Result<Option<String>, String>
where
F: FnMut(AiAgentStreamEvent),
{
let tool_calls = openai_tool_calls(json)?;
if tool_calls.is_empty() {
return Ok(None);
}
run_openai_tool_calls(request, &tool_calls, emit).map(Some)
}
fn openai_chat_messages(request: &AiModelStreamRequest) -> Vec<serde_json::Value> {
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 }));
messages
}
fn should_offer_openai_tools(request: &AiModelStreamRequest) -> bool {
let has_active_vault = non_empty_option(request.vault_path.as_deref()).is_some();
has_active_vault
&& (request.provider.kind == AiModelProviderKind::OpenAi
|| selected_model_supports_tools(request))
}
fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool {
request
.provider
.models
.iter()
.find(|model| model.id == request.model_id)
.is_some_and(|model| model.capabilities.tools)
}
fn openai_create_note_tool() -> serde_json::Value {
serde_json::from_str(CREATE_NOTE_TOOL_JSON).expect("create_note tool schema must be valid JSON")
}
fn run_openai_tool_calls<F>(
request: &AiModelStreamRequest,
tool_calls: &[OpenAiToolCall],
mut emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let mut summaries = Vec::new();
for tool_call in tool_calls {
summaries.push(execute_openai_tool_call(request, tool_call, &mut emit)?);
}
Ok(summaries.join("\n"))
}
fn execute_openai_tool_call<F>(
request: &AiModelStreamRequest,
tool_call: &OpenAiToolCall,
emit: &mut F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
if tool_call.name != CREATE_NOTE_TOOL_NAME {
return Err(format!(
"AI provider requested unsupported tool: {}",
tool_call.name
));
}
emit(AiAgentStreamEvent::ToolStart {
tool_name: CREATE_NOTE_TOOL_NAME.into(),
tool_id: tool_call.id.clone(),
input: Some(tool_call.raw_arguments.clone()),
});
match create_note_from_tool_args(request, &tool_call.arguments) {
Ok(result) => {
emit(AiAgentStreamEvent::ToolDone {
tool_id: tool_call.id.clone(),
output: Some(result.output),
});
Ok(result.summary)
}
Err(error) => {
emit(AiAgentStreamEvent::ToolDone {
tool_id: tool_call.id.clone(),
output: Some(format!("Error: {error}")),
});
Err(error)
}
}
}
fn openai_tool_calls(json: &serde_json::Value) -> Result<Vec<OpenAiToolCall>, String> {
let Some(calls) = json["choices"][0]["message"]["tool_calls"].as_array() else {
return Ok(Vec::new());
};
calls
.iter()
.enumerate()
.map(|(index, call)| openai_tool_call(index, call))
.collect()
}
fn openai_tool_call(index: usize, call: &serde_json::Value) -> Result<OpenAiToolCall, String> {
let function = &call["function"];
let name = function["name"]
.as_str()
.ok_or_else(|| "AI provider tool call did not include a function name.".to_string())?
.to_string();
let (arguments, raw_arguments) = parse_tool_arguments(&function["arguments"])?;
let id = call["id"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| format!("tool_call_{index}"));
Ok(OpenAiToolCall {
id,
name,
arguments,
raw_arguments,
})
}
fn parse_tool_arguments(value: &serde_json::Value) -> Result<(serde_json::Value, String), String> {
if let Some(raw) = value.as_str() {
let parsed = serde_json::from_str(raw)
.map_err(|error| format!("Failed to parse AI tool arguments: {error}"))?;
return Ok((parsed, raw.to_string()));
}
if value.is_object() {
let raw = serde_json::to_string(value)
.map_err(|error| format!("Failed to serialize AI tool arguments: {error}"))?;
return Ok((value.clone(), raw));
}
Ok((serde_json::json!({}), "{}".into()))
}
fn create_note_from_tool_args(
request: &AiModelStreamRequest,
args: &serde_json::Value,
) -> Result<CreatedNoteToolResult, String> {
let note_path = required_tool_string(args, "path")?;
let content = create_note_tool_content(args, note_path);
let vault_path = tool_vault_path(request, args)?;
crate::commands::create_note_content(
PathBuf::from(note_path),
content,
Some(PathBuf::from(vault_path)),
)?;
let output = serde_json::json!({
"path": note_path,
"vaultPath": vault_path,
})
.to_string();
Ok(CreatedNoteToolResult {
summary: format!("Created note: {note_path}"),
output,
})
}
fn tool_vault_path<'a>(
request: &'a AiModelStreamRequest,
args: &'a serde_json::Value,
) -> Result<&'a str, String> {
if let Some(vault_path) = string_arg(args, "vaultPath") {
return active_tool_vault_path(request, vault_path);
}
request
.vault_path
.as_deref()
.and_then(non_empty_str)
.ok_or_else(|| "No active vault is available for create_note.".to_string())
}
fn active_tool_vault_path<'a>(
request: &'a AiModelStreamRequest,
vault_path: &'a str,
) -> Result<&'a str, String> {
if active_vault_paths(request).any(|active| active == vault_path) {
Ok(vault_path)
} else {
Err(format!("Vault is not active in Tolaria: {vault_path}"))
}
}
fn active_vault_paths(request: &AiModelStreamRequest) -> impl Iterator<Item = &str> {
request
.vault_path
.as_deref()
.into_iter()
.chain(request.vault_paths.iter().map(String::as_str))
.filter_map(non_empty_str)
}
fn create_note_tool_content(args: &serde_json::Value, note_path: &str) -> String {
if let Some(content) = content_arg(args, "content") {
return content.to_string();
}
let title = string_arg(args, "title")
.map(str::to_string)
.unwrap_or_else(|| fallback_note_title(note_path));
let note_type = string_arg(args, "type")
.or_else(|| string_arg(args, "is_a"))
.unwrap_or("Note");
let note_type_yaml = serde_json::to_string(note_type).unwrap_or_else(|_| "\"Note\"".into());
format!("---\ntype: {note_type_yaml}\n---\n\n# {title}\n")
}
fn fallback_note_title(note_path: &str) -> String {
let normalized = note_path.replace('\\', "/");
Path::new(&normalized)
.file_stem()
.and_then(|stem| stem.to_str())
.filter(|title| !title.trim().is_empty())
.unwrap_or("Untitled")
.to_string()
}
fn required_tool_string<'a>(args: &'a serde_json::Value, key: &str) -> Result<&'a str, String> {
string_arg(args, key).ok_or_else(|| format!("create_note requires {key}."))
}
fn string_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> {
args[key].as_str().and_then(non_empty_str)
}
fn content_arg<'a>(args: &'a serde_json::Value, key: &str) -> Option<&'a str> {
args[key]
.as_str()
.filter(|content| !content.trim().is_empty())
}
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))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai_models::{
AiModelApiKeyStorage, AiModelCapabilities, AiModelDefinition, AiModelProvider,
};
use serde_json::json;
use std::fs;
const CREATED_NOTE_PATH: &str = "nota-longa-teste-gerada-2.md";
const CREATED_NOTE_CONTENT: &str = "---\ntype: Note\n---\n\n# Nota longa de teste - gerada 2\n";
fn request(vault_path: String) -> AiModelStreamRequest {
request_with_provider(provider(), Some(vault_path), Vec::new())
}
fn request_with_provider(
provider: AiModelProvider,
vault_path: Option<String>,
vault_paths: Vec<String>,
) -> AiModelStreamRequest {
AiModelStreamRequest {
provider,
model_id: "gpt-5-nano".into(),
message: "Create the note".into(),
system_prompt: Some("Use create_note for new notes.".into()),
vault_path,
vault_paths,
api_key_override: None,
event_name: None,
}
}
fn provider() -> AiModelProvider {
AiModelProvider {
id: "openai".into(),
name: "OpenAI".into(),
kind: AiModelProviderKind::OpenAi,
base_url: Some("https://api.openai.com/v1".into()),
api_key_storage: Some(AiModelApiKeyStorage::LocalFile),
api_key_env_var: None,
headers: None,
models: vec![AiModelDefinition {
id: "gpt-5-nano".into(),
display_name: None,
context_window: None,
max_output_tokens: None,
capabilities: AiModelCapabilities {
streaming: false,
tools: false,
vision: false,
json_mode: false,
reasoning: false,
},
}],
}
}
fn create_note_response() -> serde_json::Value {
tool_call_response(json!({
"id": "call_create",
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": serde_json::to_string(&json!({
"path": CREATED_NOTE_PATH,
"content": CREATED_NOTE_CONTENT
})).unwrap()
}
}))
}
fn tool_call_response(tool_call: serde_json::Value) -> serde_json::Value {
json!({
"choices": [{
"message": {
"tool_calls": [tool_call]
}
}]
})
}
fn create_note_response_with_args(arguments: serde_json::Value) -> serde_json::Value {
tool_call_response(json!({
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": arguments,
}
}))
}
fn create_note_error(arguments: serde_json::Value) -> String {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
execute_openai_tool_calls(&request, &create_note_response_with_args(arguments), |_| {})
.unwrap_err()
}
fn assert_note_created(vault_path: &Path) {
let actual = fs::read_to_string(vault_path.join(CREATED_NOTE_PATH)).unwrap();
assert_eq!(actual, CREATED_NOTE_CONTENT);
}
fn assert_summary(summary: Option<String>) {
assert_eq!(
summary.as_deref(),
Some("Created note: nota-longa-teste-gerada-2.md"),
);
}
fn assert_tool_events(events: &[AiAgentStreamEvent]) {
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, input: Some(input) }
if tool_name == CREATE_NOTE_TOOL_NAME && tool_id == "call_create" && input.contains(CREATED_NOTE_PATH)
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output: Some(output) }
if tool_id == "call_create" && output.contains(CREATED_NOTE_PATH)
));
}
#[test]
fn openai_payload_offers_create_note_when_active_vault_is_loaded() {
let dir = tempfile::tempdir().unwrap();
let payload = openai_chat_payload(&request(dir.path().to_string_lossy().into_owned()));
assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME);
}
#[test]
fn openai_payload_offers_create_note_for_tool_capable_custom_models() {
let dir = tempfile::tempdir().unwrap();
let mut provider = provider();
provider.kind = AiModelProviderKind::OpenAiCompatible;
provider.models[0].capabilities.tools = true;
let request = request_with_provider(
provider,
Some(dir.path().to_string_lossy().into_owned()),
vec![],
);
let payload = openai_chat_payload(&request);
assert!(payload["tools"][0]["function"]["name"] == CREATE_NOTE_TOOL_NAME);
}
#[test]
fn openai_payload_skips_create_note_when_no_active_vault_is_loaded() {
let payload = openai_chat_payload(&request_with_provider(provider(), None, Vec::new()));
assert!(payload.get("tools").is_none());
assert!(payload.get("tool_choice").is_none());
}
#[test]
fn execute_openai_tool_calls_returns_none_without_tool_calls() {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
let summary = execute_openai_tool_calls(
&request,
&json!({ "choices": [{ "message": { "content": "No tools" } }] }),
|_| {},
)
.unwrap();
assert_eq!(summary, None);
}
#[test]
fn executes_openai_create_note_tool_call_inside_active_vault() {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
let mut events = Vec::new();
let summary = execute_openai_tool_calls(&request, &create_note_response(), |event| {
events.push(event)
})
.unwrap();
assert_note_created(dir.path());
assert_summary(summary);
assert_tool_events(&events);
}
#[test]
fn executes_openai_create_note_with_object_arguments_and_selected_vault() {
let primary = tempfile::tempdir().unwrap();
let secondary = tempfile::tempdir().unwrap();
let secondary_path = secondary.path().to_string_lossy().into_owned();
let request = request_with_provider(
provider(),
Some(primary.path().to_string_lossy().into_owned()),
vec![secondary_path.clone()],
);
let response = tool_call_response(json!({
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": {
"path": "Generated/fallback-note.md",
"is_a": "Project",
"vaultPath": secondary_path,
}
}
}));
let summary = execute_openai_tool_calls(&request, &response, |_| {}).unwrap();
assert_eq!(
fs::read_to_string(secondary.path().join("Generated/fallback-note.md")).unwrap(),
"---\ntype: \"Project\"\n---\n\n# fallback-note\n",
);
assert_eq!(
summary.as_deref(),
Some("Created note: Generated/fallback-note.md")
);
assert!(!primary.path().join("Generated/fallback-note.md").exists());
}
#[test]
fn execute_openai_tool_calls_rejects_unsupported_tool_before_running_it() {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
let response = tool_call_response(json!({
"id": "call_delete",
"function": {
"name": "delete_note",
"arguments": "{}",
}
}));
let mut events = Vec::new();
let error =
execute_openai_tool_calls(&request, &response, |event| events.push(event)).unwrap_err();
assert_eq!(error, "AI provider requested unsupported tool: delete_note");
assert!(events.is_empty());
}
#[test]
fn execute_openai_tool_calls_rejects_malformed_arguments() {
let error = create_note_error(json!("{not-json"));
assert!(error.contains("Failed to parse AI tool arguments"));
}
#[test]
fn execute_openai_tool_calls_treats_non_object_arguments_as_empty() {
let error = create_note_error(json!([]));
assert_eq!(error, "create_note requires path.");
}
#[test]
fn execute_openai_tool_calls_rejects_existing_note_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
let request = request(dir.path().to_string_lossy().into_owned());
fs::write(dir.path().join("existing.md"), "# Existing\n").unwrap();
let response = tool_call_response(json!({
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": serde_json::to_string(&json!({
"path": "existing.md",
"content": "# Replacement\n",
})).unwrap(),
}
}));
let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err();
assert!(error.contains("already exists"));
assert_eq!(
fs::read_to_string(dir.path().join("existing.md")).unwrap(),
"# Existing\n",
);
}
#[test]
fn execute_openai_tool_calls_requires_path() {
let error = create_note_error(json!("{}"));
assert_eq!(error, "create_note requires path.");
}
#[test]
fn execute_openai_tool_calls_rejects_inactive_explicit_vault() {
let active = tempfile::tempdir().unwrap();
let inactive = tempfile::tempdir().unwrap();
let request = request(active.path().to_string_lossy().into_owned());
let response = tool_call_response(json!({
"function": {
"name": CREATE_NOTE_TOOL_NAME,
"arguments": serde_json::to_string(&json!({
"path": "inactive.md",
"content": "# Inactive\n",
"vaultPath": inactive.path().to_string_lossy(),
})).unwrap(),
}
}));
let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err();
assert!(error.starts_with("Vault is not active in Tolaria:"));
assert!(!inactive.path().join("inactive.md").exists());
}
}

View File

@@ -61,7 +61,12 @@ pub struct AiModelStreamRequest {
pub model_id: String,
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: Option<String>,
#[serde(default)]
pub vault_paths: Vec<String>,
pub api_key_override: Option<String>,
#[serde(default)]
pub event_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
@@ -164,7 +169,7 @@ where
session_id: format!("api-{}", uuid::Uuid::new_v4()),
});
let text = send_model_message(&request)?;
let text = send_model_message(&request, &mut emit)?;
emit(AiAgentStreamEvent::TextDelta { text });
emit(AiAgentStreamEvent::Done);
@@ -180,32 +185,39 @@ pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<Str
"You are testing whether this model endpoint is reachable. Reply with exactly OK."
.into(),
),
vault_path: None,
vault_paths: Vec::new(),
api_key_override: normalize_optional_string(request.api_key_override),
event_name: None,
};
send_model_message(&request)
send_model_message(&request, &mut |_| {})
}
fn send_model_message(request: &AiModelStreamRequest) -> Result<String, String> {
fn send_model_message<F>(request: &AiModelStreamRequest, emit: &mut F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
match request.provider.kind {
AiModelProviderKind::Anthropic => send_anthropic_message(request),
_ => send_openai_compatible_message(request),
_ => send_openai_compatible_message(request, emit),
}
}
fn send_openai_compatible_message(request: &AiModelStreamRequest) -> Result<String, String> {
fn send_openai_compatible_message<F>(
request: &AiModelStreamRequest,
emit: &mut F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
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 payload = crate::ai_model_tools::openai_chat_payload(request);
let json = send_json_request(request, endpoint, payload)?;
if let Some(tool_summary) =
crate::ai_model_tools::execute_openai_tool_calls(request, &json, emit)?
{
return Ok(tool_summary);
}
extract_openai_text(&json)
}
@@ -420,18 +432,32 @@ fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<Stri
}
fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
api_key_from_env_with_lookup(
request,
crate::cli_agent_runtime::env_value_from_process_or_user_shell,
)
}
fn api_key_from_env_with_lookup(
request: &AiModelStreamRequest,
lookup: impl Fn(crate::cli_agent_runtime::EnvName<'_>) -> Option<String>,
) -> Result<Option<String>, String> {
let Some(name) = request
.provider
.api_key_env_var
.as_deref()
.and_then(non_empty_str)
.and_then(crate::cli_agent_runtime::EnvName::new)
else {
return Ok(None);
};
std::env::var(name)
.map(Some)
.map_err(|_| format!("Environment variable {name} is not set for this AI provider."))
lookup(name).map(Some).ok_or_else(|| {
format!(
"Environment variable {} is not set for this AI provider.",
name.as_str()
)
})
}
fn api_key_from_provider(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
@@ -534,7 +560,10 @@ mod tests {
model_id: "demo-model".into(),
message: "Hello".into(),
system_prompt: Some(" Be concise. ".into()),
vault_path: None,
vault_paths: Vec::new(),
api_key_override: None,
event_name: None,
}
}
@@ -605,6 +634,51 @@ mod tests {
assert_eq!(headers, vec![("X-Demo", "demo")]);
}
#[test]
fn request_builders_apply_auth_and_provider_headers() {
let client = reqwest::blocking::Client::new();
let mut anthropic_provider = provider(AiModelProviderKind::Anthropic);
anthropic_provider.api_key_env_var = None;
anthropic_provider.headers = Some(BTreeMap::from([
("Authorization".into(), "ignored".into()),
("X-Demo".into(), "demo".into()),
]));
let mut anthropic_request = request(anthropic_provider);
anthropic_request.api_key_override = Some(" secret ".into());
let built = apply_provider_headers(
apply_auth_headers(client.post("https://example.test"), &anthropic_request).unwrap(),
&anthropic_request,
)
.build()
.unwrap();
let headers = built.headers();
assert_eq!(
(
headers["x-api-key"].to_str().unwrap(),
headers["anthropic-version"].to_str().unwrap(),
headers["X-Demo"].to_str().unwrap(),
headers.get("authorization").is_none(),
),
("secret", "2023-06-01", "demo", true),
);
let mut openai_provider = provider(AiModelProviderKind::OpenAi);
openai_provider.api_key_env_var = None;
let mut openai_request = request(openai_provider);
openai_request.api_key_override = Some(" openai-secret ".into());
let built = apply_auth_headers(client.post("https://example.test"), &openai_request)
.unwrap()
.build()
.unwrap();
assert_eq!(
built.headers()["authorization"].to_str().unwrap(),
"Bearer openai-secret"
);
}
#[test]
fn shared_provider_catalog_supplies_runtime_base_urls() {
assert_eq!(
@@ -632,6 +706,19 @@ mod tests {
);
}
#[test]
fn env_api_key_uses_shell_lookup_when_process_env_is_missing() {
let mut provider = provider(AiModelProviderKind::Anthropic);
provider.api_key_storage = Some(AiModelApiKeyStorage::Env);
provider.api_key_env_var = Some("ANTHROPIC_API_KEY".into());
let request = request(provider);
let api_key =
api_key_from_env_with_lookup(&request, |_| Some("shell-secret".to_string())).unwrap();
assert_eq!(api_key.as_deref(), Some("shell-secret"));
}
#[test]
fn extracts_provider_text_payloads_and_reports_empty_responses() {
let openai = json!({

View File

@@ -1,9 +1,36 @@
pub use crate::cli_agent_runtime::AgentStreamRequest;
use crate::cli_agent_runtime::EnvName;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
const CLAUDE_PROVIDER_ENV_KEYS: &[EnvName<'static>] = &[
EnvName::trusted("ANTHROPIC_API_KEY"),
EnvName::trusted("ANTHROPIC_AUTH_TOKEN"),
EnvName::trusted("ANTHROPIC_BASE_URL"),
EnvName::trusted("ANTHROPIC_CUSTOM_HEADERS"),
EnvName::trusted("ANTHROPIC_MODEL"),
EnvName::trusted("ANTHROPIC_SMALL_FAST_MODEL"),
EnvName::trusted("CLAUDE_CODE_USE_BEDROCK"),
EnvName::trusted("CLAUDE_CODE_USE_VERTEX"),
EnvName::trusted("AWS_ACCESS_KEY_ID"),
EnvName::trusted("AWS_SECRET_ACCESS_KEY"),
EnvName::trusted("AWS_SESSION_TOKEN"),
EnvName::trusted("AWS_PROFILE"),
EnvName::trusted("AWS_REGION"),
EnvName::trusted("AWS_DEFAULT_REGION"),
EnvName::trusted("GOOGLE_APPLICATION_CREDENTIALS"),
EnvName::trusted("CLOUD_ML_REGION"),
EnvName::trusted("VERTEX_REGION"),
EnvName::trusted("HTTPS_PROXY"),
EnvName::trusted("HTTP_PROXY"),
EnvName::trusted("NO_PROXY"),
EnvName::trusted("SSL_CERT_FILE"),
EnvName::trusted("SSL_CERT_DIR"),
EnvName::trusted("NODE_EXTRA_CA_CERTS"),
];
/// Status returned by `check_claude_cli`.
#[derive(Debug, Serialize, Clone)]
pub struct ClaudeCliStatus {
@@ -393,7 +420,7 @@ fn build_claude_command(
) -> Result<std::process::Command, String> {
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(request.bin)?;
let mut cmd = crate::hidden_command(&target.program);
crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, request.bin);
configure_claude_command_environment(&mut cmd, request.bin);
if let Some(first_arg) = target.first_arg {
cmd.arg(first_arg);
}
@@ -408,6 +435,11 @@ fn build_claude_command(
Ok(cmd)
}
fn configure_claude_command_environment(cmd: &mut std::process::Command, bin: &Path) {
crate::cli_agent_runtime::configure_agent_command_environment(cmd, bin);
crate::cli_agent_runtime::apply_user_shell_env_vars_if_missing(cmd, CLAUDE_PROVIDER_ENV_KEYS);
}
fn format_failed_claude_exit(failure: ClaudeFailure<'_>) -> String {
if is_claude_auth_error(failure.stderr) {
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
@@ -1148,6 +1180,12 @@ mod tests {
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault")));
}
#[test]
fn claude_provider_env_keys_include_reported_anthropic_overrides() {
assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_API_KEY")));
assert!(CLAUDE_PROVIDER_ENV_KEYS.contains(&EnvName::trusted("ANTHROPIC_BASE_URL")));
}
#[cfg(unix)]
#[derive(Clone, Copy)]
struct MockClaudeScript<'a>(&'a str);
@@ -1265,6 +1303,7 @@ mod tests {
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[cfg(unix)]
#[test]
fn run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open() {
use std::io::Read;
@@ -1283,7 +1322,7 @@ mod tests {
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 deadline = Instant::now() + Duration::from_secs(30);
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
@@ -1309,6 +1348,7 @@ mod tests {
);
}
#[cfg(unix)]
#[ignore = "spawned by run_subprocess_closes_stdin_even_when_parent_stdin_pipe_is_open"]
#[test]
fn stdin_probe_parent_child() {
@@ -1316,25 +1356,15 @@ mod tests {
return;
}
let fake_bin = current_test_binary();
let args = vec![
"stdin_probe_mock_claude_child".to_string(),
"--ignored".to_string(),
"--nocapture".to_string(),
];
std::env::set_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD", "1");
let mut events = vec![];
let result = run_claude_subprocess(
ClaudeSubprocessRequest {
bin: &fake_bin,
args: &args,
fallback_args: &[],
stdin_text: None,
cwd: None,
},
&mut |event| events.push(event),
);
std::env::remove_var("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD");
let (result, events) = run_mock_script(MockClaudeScript(concat!(
"#!/bin/sh\n",
"stdin=\"$(cat)\"\n",
"if [ -n \"$stdin\" ]; then\n",
" echo \"stdin was not closed\" >&2\n",
" exit 9\n",
"fi\n",
"printf '%s\\n' '{\"type\":\"result\",\"result\":\"stdin closed\",\"session_id\":\"stdin-ok\"}'\n",
)));
assert_eq!(result.unwrap(), "stdin-ok");
assert!(matches!(
@@ -1345,28 +1375,6 @@ mod tests {
assert!(matches!(events.last(), Some(ClaudeStreamEvent::Done)));
}
#[ignore = "spawned by stdin_probe_parent_child"]
#[test]
fn stdin_probe_mock_claude_child() {
if std::env::var_os("TOLARIA_STDIN_PROBE_MOCK_CLAUDE_CHILD").is_none() {
return;
}
use std::io::Read;
let mut stdin = String::new();
std::io::stdin().read_to_string(&mut stdin).unwrap();
assert!(stdin.is_empty(), "stdin was not EOF");
println!(
"{}",
serde_json::json!({
"type": "result",
"result": "stdin closed",
"session_id": "stdin-ok"
})
);
}
#[cfg(unix)]
#[test]
fn run_subprocess_skips_blank_and_non_json_lines() {

View File

@@ -5,6 +5,13 @@ use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
mod shell_env;
mod windows_cmd_shim;
pub(crate) use shell_env::{
apply_user_shell_env_vars_if_missing, env_value_from_process_or_user_shell, EnvName,
};
#[derive(Debug, Clone, Deserialize)]
pub struct AgentStreamRequest {
pub message: String,
@@ -107,13 +114,8 @@ pub(crate) fn version_for_binary(binary: &Path) -> Option<String> {
pub(crate) fn command_target_avoiding_windows_cmd_shim(
binary: &Path,
) -> Result<AgentCommandTarget, String> {
if is_windows_batch_shim(binary) {
if let Some(script) = node_script_from_windows_cmd_shim(binary) {
return Ok(AgentCommandTarget {
program: crate::mcp::find_node()?,
first_arg: Some(script),
});
}
if let Some(target) = windows_cmd_shim::command_target(binary)? {
return Ok(target);
}
Ok(AgentCommandTarget {
@@ -244,43 +246,6 @@ pub(crate) fn has_windows_cli_extension(path: &Path) -> bool {
})
}
fn is_windows_batch_shim(binary: &Path) -> bool {
binary
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
})
}
fn node_script_from_windows_cmd_shim(binary: &Path) -> Option<PathBuf> {
let contents = std::fs::read_to_string(binary).ok()?;
contents
.split('"')
.skip(1)
.step_by(2)
.filter(|token| is_node_script_token(token))
.find_map(|token| resolve_cmd_shim_script_path(binary, token))
}
fn is_node_script_token(token: &str) -> bool {
let lower = token.to_ascii_lowercase();
(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs"))
&& (lower.starts_with("%dp0%") || lower.starts_with("%~dp0"))
}
fn resolve_cmd_shim_script_path(binary: &Path, token: &str) -> Option<PathBuf> {
let relative = token
.strip_prefix("%dp0%")
.or_else(|| token.strip_prefix("%~dp0"))?
.trim_start_matches(['\\', '/']);
let mut script = binary.parent()?.to_path_buf();
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
script.push(part);
}
script.is_file().then_some(script)
}
pub(crate) fn parse_json_line(
line: Result<String, std::io::Error>,
) -> Result<Option<serde_json::Value>, String> {

View File

@@ -0,0 +1,313 @@
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const OUTPUT_PREFIX: &str = "__TOLARIA_ENV__:";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct EnvName<'a>(&'a str);
impl<'a> EnvName<'a> {
pub(crate) fn new(raw: &'a str) -> Option<Self> {
is_valid_name(raw).then_some(Self(raw))
}
pub(crate) const fn trusted(raw: &'a str) -> Self {
Self(raw)
}
pub(crate) fn as_str(self) -> &'a str {
self.0
}
}
pub(crate) fn apply_user_shell_env_vars_if_missing(command: &mut Command, names: &[EnvName<'_>]) {
let missing = valid_unique_names(names)
.into_iter()
.filter(|name| !process_has_value(name) && !command_has_value(command, name))
.collect::<Vec<_>>();
for binding in user_shell_bindings(&missing) {
command.env(binding.name, binding.value);
}
}
pub(crate) fn env_value_from_process_or_user_shell(name: EnvName<'_>) -> Option<String> {
process_value(name).or_else(|| user_shell_value(name))
}
#[derive(Debug, PartialEq, Eq)]
struct EnvBinding {
name: String,
value: String,
}
fn process_has_value(name: &EnvName<'_>) -> bool {
std::env::var_os(name.as_str()).is_some_and(|value| !value.is_empty())
}
fn command_has_value(command: &Command, name: &EnvName<'_>) -> bool {
command.get_envs().any(|(key, value)| {
key == OsStr::new(name.as_str()) && value.is_some_and(|value| !value.is_empty())
})
}
fn process_value(name: EnvName<'_>) -> Option<String> {
std::env::var(name.as_str())
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn user_shell_value(name: EnvName<'_>) -> Option<String> {
user_shell_bindings(&[name])
.into_iter()
.find_map(|binding| (binding.name == name.as_str()).then_some(binding.value))
}
fn user_shell_bindings(names: &[EnvName<'_>]) -> Vec<EnvBinding> {
let names = valid_unique_names(names);
if names.is_empty() {
return Vec::new();
}
user_shell_bindings_for_platform(&names)
}
fn valid_unique_names<'a>(names: &[EnvName<'a>]) -> Vec<EnvName<'a>> {
let mut unique = Vec::new();
for name in names.iter().copied() {
if !unique.iter().any(|existing| existing == &name) {
unique.push(name);
}
}
unique
}
fn is_valid_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first.is_ascii_alphabetic())
&& chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
#[cfg(unix)]
fn user_shell_bindings_for_platform(names: &[EnvName<'_>]) -> Vec<EnvBinding> {
shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| user_shell_bindings_from_shell(&shell, names))
.unwrap_or_default()
}
#[cfg(not(unix))]
fn user_shell_bindings_for_platform(_names: &[EnvName<'_>]) -> Vec<EnvBinding> {
Vec::new()
}
#[cfg(unix)]
fn user_shell_bindings_from_shell(shell: &Path, names: &[EnvName<'_>]) -> Option<Vec<EnvBinding>> {
let output = crate::hidden_command(shell)
.arg("-lc")
.arg(shell_probe_script(shell, names))
.stdin(Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let bindings = parse_probe_output(&String::from_utf8_lossy(&output.stdout), names);
(!bindings.is_empty()).then_some(bindings)
}
#[cfg(unix)]
fn shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
#[cfg(unix)]
fn shell_probe_script(shell: &Path, names: &[EnvName<'_>]) -> String {
format!(
"{}\nfor name in {}; do\n value=$(printenv \"$name\" 2>/dev/null || true)\n if [ -n \"$value\" ]; then\n printf '{}%s=%s\\n' \"$name\" \"$value\"\n fi\ndone\n",
rc_source_command(shell),
joined_names(names),
OUTPUT_PREFIX
)
}
fn joined_names(names: &[EnvName<'_>]) -> String {
names
.iter()
.map(|name| name.as_str())
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(unix)]
fn rc_source_command(shell: &Path) -> &'static str {
let name = shell
.file_name()
.and_then(OsStr::to_str)
.unwrap_or_default();
if name.contains("zsh") {
return "if [ -n \"${ZDOTDIR:-}\" ] && [ -r \"${ZDOTDIR}/.zshrc\" ]; then . \"${ZDOTDIR}/.zshrc\" >/dev/null 2>&1 || true; elif [ -r \"$HOME/.zshrc\" ]; then . \"$HOME/.zshrc\" >/dev/null 2>&1 || true; fi";
}
if name.contains("bash") {
return "if [ -r \"$HOME/.bashrc\" ]; then . \"$HOME/.bashrc\" >/dev/null 2>&1 || true; fi";
}
""
}
struct ProbeOutput<'a>(&'a str);
struct ProbeLine<'a>(&'a str);
fn parse_probe_output(stdout: &str, names: &[EnvName<'_>]) -> Vec<EnvBinding> {
ProbeOutput(stdout)
.0
.lines()
.filter_map(|line| parse_probe_line(ProbeLine(line), names))
.collect()
}
fn parse_probe_line(line: ProbeLine<'_>, names: &[EnvName<'_>]) -> Option<EnvBinding> {
let (name, value) = line.0.strip_prefix(OUTPUT_PREFIX)?.split_once('=')?;
let name = EnvName::new(name)?;
let value = value.trim();
(names.iter().any(|expected| expected == &name) && !value.is_empty()).then(|| EnvBinding {
name: name.as_str().to_string(),
value: value.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_value_uses_trimmed_process_value() {
let key = "TOLARIA_TEST_SHELL_ENV_PROCESS_VALUE";
std::env::set_var(key, " process-secret ");
let value = env_value_from_process_or_user_shell(EnvName::trusted(key));
std::env::remove_var(key);
assert_eq!(value.as_deref(), Some("process-secret"));
}
#[test]
fn command_value_marks_name_as_already_available() {
let mut command = Command::new("demo");
command.env("TOLARIA_TEST_COMMAND_VALUE", "command-secret");
apply_user_shell_env_vars_if_missing(
&mut command,
&[EnvName::trusted("TOLARIA_TEST_COMMAND_VALUE")],
);
let values = command
.get_envs()
.map(|(key, value)| (key.to_string_lossy().to_string(), value.is_some()))
.collect::<Vec<_>>();
assert_eq!(values, vec![("TOLARIA_TEST_COMMAND_VALUE".into(), true)]);
}
#[test]
fn parse_probe_output_filters_invalid_unexpected_and_blank_values() {
let names = [
EnvName::trusted("GOOD_VALUE"),
EnvName::trusted("OTHER_VALUE"),
];
let bindings = parse_probe_output(
"__TOLARIA_ENV__:GOOD_VALUE=kept\n\
ignored\n\
__TOLARIA_ENV__:BAD-NAME=bad\n\
__TOLARIA_ENV__:OTHER_VALUE= \n\
__TOLARIA_ENV__:UNEXPECTED=value\n",
&names,
);
assert_eq!(
bindings,
vec![EnvBinding {
name: "GOOD_VALUE".into(),
value: "kept".into(),
}]
);
}
#[cfg(unix)]
#[test]
fn user_shell_bindings_from_shell_returns_none_for_failing_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let shell = dir.path().join("zsh");
std::fs::write(&shell, "#!/bin/sh\nexit 7\n").unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
let values = user_shell_bindings_from_shell(&shell, &[EnvName::trusted("MISSING")]);
assert_eq!(values, None);
}
#[cfg(unix)]
#[test]
fn rc_source_command_ignores_unknown_shells() {
assert_eq!(rc_source_command(Path::new("fish")), "");
}
#[cfg(unix)]
#[test]
fn user_shell_bindings_from_shell_reads_zshrc_exports_for_requested_keys() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let shell = dir.path().join("zsh");
let zshrc = dir.path().join(".zshrc");
std::fs::write(
&shell,
"#!/bin/sh\nexport HOME=$(dirname \"$0\")\nexec /bin/sh -c \"$2\"\n",
)
.unwrap();
std::fs::write(
&zshrc,
"export ANTHROPIC_API_KEY=from-zshrc\nexport ANTHROPIC_BASE_URL=https://proxy.example.test\n",
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
let values = user_shell_bindings_from_shell(
&shell,
&[
EnvName::trusted("ANTHROPIC_API_KEY"),
EnvName::trusted("ANTHROPIC_BASE_URL"),
EnvName::trusted("IGNORED_SECRET"),
],
)
.expect("zshrc exports should be readable");
assert_eq!(
values,
vec![
EnvBinding {
name: "ANTHROPIC_API_KEY".to_string(),
value: "from-zshrc".to_string(),
},
EnvBinding {
name: "ANTHROPIC_BASE_URL".to_string(),
value: "https://proxy.example.test".to_string(),
},
]
);
}
}

View File

@@ -0,0 +1,115 @@
use super::AgentCommandTarget;
use std::path::{Path, PathBuf};
pub(super) fn command_target(binary: &Path) -> Result<Option<AgentCommandTarget>, String> {
if !is_batch_shim(binary) {
return Ok(None);
}
let Some(target) = target_path(binary) else {
return Ok(None);
};
if is_node_script(&target) {
return Ok(Some(AgentCommandTarget {
program: crate::mcp::find_node()?,
first_arg: Some(target),
}));
}
Ok(Some(AgentCommandTarget {
program: target,
first_arg: None,
}))
}
fn is_batch_shim(binary: &Path) -> bool {
has_extension(binary, &["cmd", "bat"])
}
fn target_path(binary: &Path) -> Option<PathBuf> {
let contents = std::fs::read_to_string(binary).ok()?;
contents
.split('"')
.skip(1)
.step_by(2)
.find_map(|token| resolve_target_path(binary, token))
}
fn resolve_target_path(binary: &Path, token: &str) -> Option<PathBuf> {
let relative = token
.strip_prefix("%dp0%")
.or_else(|| token.strip_prefix("%~dp0"))?
.trim_start_matches(['\\', '/']);
let mut target = binary.parent()?.to_path_buf();
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
target.push(part);
}
is_supported_target(&target)
.then_some(target)
.filter(|target| target.is_file())
}
fn is_supported_target(path: &Path) -> bool {
is_node_script(path) || is_native_executable(path)
}
fn is_node_script(path: &Path) -> bool {
has_extension(path, &["js", "mjs", "cjs"])
}
fn is_native_executable(path: &Path) -> bool {
has_extension(path, &["exe", "com"]) && !has_file_name(path, "node.exe")
}
fn has_extension(path: &Path, expected_extensions: &[&str]) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
expected_extensions
.iter()
.any(|expected| extension.eq_ignore_ascii_case(expected))
})
}
fn has_file_name(path: &Path, expected_name: &str) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(expected_name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_target_uses_native_exe_from_windows_cmd_shim() {
let dir = tempfile::tempdir().unwrap();
let shim = dir.path().join("claude.cmd");
let native_exe = dir
.path()
.join("node_modules")
.join("@anthropic-ai")
.join("claude-code")
.join("bin")
.join("claude.exe");
std::fs::create_dir_all(native_exe.parent().unwrap()).unwrap();
std::fs::write(dir.path().join("node.exe"), "node runtime").unwrap();
std::fs::write(&native_exe, "native claude launcher").unwrap();
std::fs::write(
&shim,
r#"@ECHO off
IF EXIST "%~dp0\node.exe" (
SET "_prog=%~dp0\node.exe"
)
"%~dp0\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %*
"#,
)
.unwrap();
let target = command_target(&shim).unwrap().unwrap();
assert_eq!(target.program, native_exe);
assert_eq!(target.first_arg, None);
}
}

View File

@@ -16,7 +16,7 @@ const AGENT_DOCS_RESOURCE_DIR: &str = "agent-docs";
#[cfg(desktop)]
async fn run_desktop_stream<Event, Request, Runner>(
app_handle: tauri::AppHandle,
event_name: &'static str,
event_name: String,
request: Request,
runner: Runner,
) -> Result<String, String>
@@ -31,7 +31,7 @@ where
runner(
request,
Box::new(move |event| {
let _ = app_handle.emit(event_name, &event);
let _ = app_handle.emit(event_name.as_str(), &event);
}),
)
})
@@ -47,11 +47,32 @@ macro_rules! define_desktop_stream_command {
app_handle: tauri::AppHandle,
request: $request,
) -> Result<String, String> {
run_desktop_stream(app_handle, $event_name, request, $runner).await
run_desktop_stream(app_handle, $event_name.to_string(), request, $runner).await
}
};
}
#[cfg(desktop)]
fn is_scoped_stream_event_name(default_event_name: &str, event_name: &str) -> bool {
event_name
.strip_prefix(default_event_name)
.and_then(|suffix| suffix.strip_prefix('-'))
.is_some_and(|suffix| {
!suffix.is_empty()
&& suffix
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
})
}
#[cfg(desktop)]
fn stream_event_name(default_event_name: &'static str, requested: Option<&str>) -> String {
requested
.filter(|event_name| is_scoped_stream_event_name(default_event_name, event_name))
.unwrap_or(default_event_name)
.to_string()
}
// ── Claude CLI commands (desktop) ───────────────────────────────────────────
#[cfg(desktop)]
@@ -62,8 +83,8 @@ pub fn check_claude_cli() -> ClaudeCliStatus {
#[cfg(desktop)]
#[tauri::command]
pub fn get_ai_agents_status() -> AiAgentsStatus {
crate::ai_agents::get_ai_agents_status()
pub async fn get_ai_agents_status() -> AiAgentsStatus {
crate::ai_agents::get_ai_agents_status().await
}
#[cfg(desktop)]
@@ -135,20 +156,36 @@ fn run_normalized_ai_agent_stream(
}
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_agent,
AiAgentStreamRequest,
"ai-agent-stream",
run_normalized_ai_agent_stream
);
#[tauri::command]
pub async fn stream_ai_agent(
app_handle: tauri::AppHandle,
request: AiAgentStreamRequest,
) -> Result<String, String> {
let event_name = stream_event_name("ai-agent-stream", request.event_name.as_deref());
run_desktop_stream(
app_handle,
event_name,
request,
run_normalized_ai_agent_stream,
)
.await
}
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_model,
AiModelStreamRequest,
"ai-model-stream",
crate::ai_models::run_ai_model_stream
);
#[tauri::command]
pub async fn stream_ai_model(
app_handle: tauri::AppHandle,
request: AiModelStreamRequest,
) -> Result<String, String> {
let event_name = stream_event_name("ai-model-stream", request.event_name.as_deref());
run_desktop_stream(
app_handle,
event_name,
request,
crate::ai_models::run_ai_model_stream,
)
.await
}
#[cfg(desktop)]
#[tauri::command]
@@ -280,6 +317,7 @@ mod tests {
vault_path: "~/Vaults/content".into(),
vault_paths: vec!["~/Vaults/secondary".into()],
permission_mode: None,
event_name: None,
};
let normalized = normalize_agent_request(request);
@@ -308,6 +346,7 @@ mod tests {
vault_path: "/Users/example/vault".into(),
vault_paths: Vec::new(),
permission_mode: None,
event_name: None,
};
let normalized = normalize_agent_request(request);
@@ -315,6 +354,23 @@ mod tests {
assert_eq!(normalized.vault_path, "/Users/example/vault");
}
#[cfg(desktop)]
#[test]
fn stream_event_name_accepts_only_scoped_names() {
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-agent-stream-chat-123")),
"ai-agent-stream-chat-123",
);
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-model-stream-chat-123")),
"ai-agent-stream",
);
assert_eq!(
stream_event_name("ai-agent-stream", Some("ai-agent-stream/../bad")),
"ai-agent-stream",
);
}
#[test]
fn guidance_commands_report_and_restore_vault_guidance_files() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -150,6 +150,19 @@ pub async fn git_remote_status(vault_path: VaultPathArg) -> Result<GitRemoteStat
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub async fn git_file_url(
vault_path: VaultPathArg,
path: NotePathArg,
) -> Result<Option<String>, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let path = expand_tilde(&path).into_owned();
tokio::task::spawn_blocking(move || crate::git::git_file_url(&vault_path, &path))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_discard_file(
@@ -344,6 +357,15 @@ pub async fn git_remote_status(_vault_path: VaultPathArg) -> Result<GitRemoteSta
})
}
#[cfg(mobile)]
#[tauri::command]
pub async fn git_file_url(
_vault_path: VaultPathArg,
_path: NotePathArg,
) -> Result<Option<String>, String> {
Ok(None)
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_discard_file(

View File

@@ -7,6 +7,7 @@ mod git;
pub mod git_clone;
mod git_connect;
mod memory;
mod pdf_export;
mod runtime;
mod system;
mod vault;
@@ -22,6 +23,7 @@ pub use folders::*;
pub use git::*;
pub use git_connect::*;
pub use memory::*;
pub use pdf_export::*;
pub use runtime::*;
pub use system::*;
pub use vault::*;

View File

@@ -0,0 +1,141 @@
#[tauri::command]
pub fn export_current_webview_pdf(
window: tauri::WebviewWindow,
output_path: String,
) -> Result<(), String> {
native::export_current_webview_pdf(window, output_path)
}
#[tauri::command]
pub fn can_export_current_webview_pdf() -> bool {
native::can_export_current_webview_pdf()
}
#[cfg(target_os = "macos")]
mod native {
use std::path::Path;
use std::sync::mpsc;
use std::time::Duration;
use objc2::runtime::ProtocolObject;
use objc2::ClassType;
use objc2_app_kit::{NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob};
use objc2_foundation::{NSString, NSURL};
use objc2_web_kit::WKWebView;
const PDF_EXPORT_TIMEOUT: Duration = Duration::from_secs(15);
pub fn can_export_current_webview_pdf() -> bool {
true
}
pub fn export_current_webview_pdf(
window: tauri::WebviewWindow,
output_path: String,
) -> Result<(), String> {
validate_output_path(&output_path)?;
let (sender, receiver) = mpsc::channel();
window
.with_webview(move |webview| {
let result = save_webview_pdf(webview, &output_path);
let _ = sender.send(result);
})
.map_err(|error| format!("Failed to access the current webview: {error}"))?;
receiver
.recv_timeout(PDF_EXPORT_TIMEOUT)
.map_err(|_| "Timed out while exporting the current note as PDF".to_string())?
}
fn validate_output_path(output_path: &str) -> Result<(), String> {
if output_path.trim().is_empty() {
return Err("Missing PDF export path".to_string());
}
let path = Path::new(output_path);
if path.file_name().is_none() {
return Err("PDF export path must include a file name".to_string());
}
Ok(())
}
fn save_webview_pdf(
webview: tauri::webview::PlatformWebview,
output_path: &str,
) -> Result<(), String> {
let output = NSString::from_str(output_path);
let output_url = NSURL::fileURLWithPath(&output);
let print_info = NSPrintInfo::sharedPrintInfo();
let print_settings = unsafe { print_info.dictionary() };
let previous_job_disposition = print_info.jobDisposition();
print_info.setJobDisposition(unsafe { NSPrintSaveJob });
unsafe {
print_settings.setObject_forKey(
output_url.as_super().as_super(),
ProtocolObject::from_ref(NSPrintJobSavingURL),
);
}
let webview: &WKWebView = unsafe { &*webview.inner().cast() };
let window = webview
.window()
.ok_or_else(|| "Failed to access the webview window for PDF export".to_string())?;
let operation = unsafe { webview.printOperationWithPrintInfo(&print_info) };
operation.setShowsPrintPanel(false);
operation.setShowsProgressPanel(false);
operation.setCanSpawnSeparateThread(true);
unsafe {
operation.runOperationModalForWindow_delegate_didRunSelector_contextInfo(
&window,
None,
None,
std::ptr::null_mut(),
);
}
print_info.setJobDisposition(&previous_job_disposition);
unsafe {
print_settings.removeObjectForKey(NSPrintJobSavingURL);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::validate_output_path;
#[test]
fn output_path_requires_a_non_blank_value() {
assert!(validate_output_path("").is_err());
assert!(validate_output_path(" ").is_err());
}
#[test]
fn output_path_requires_a_file_name() {
assert!(validate_output_path("/").is_err());
}
#[test]
fn output_path_accepts_a_pdf_file_path() {
assert!(validate_output_path("/tmp/tolaria-note.pdf").is_ok());
}
}
}
#[cfg(not(target_os = "macos"))]
mod native {
pub fn can_export_current_webview_pdf() -> bool {
false
}
pub fn export_current_webview_pdf(
_window: tauri::WebviewWindow,
_output_path: String,
) -> Result<(), String> {
Err("Direct PDF export is currently only supported on macOS".to_string())
}
}

View File

@@ -2,6 +2,10 @@ fn should_use_external_media_preview_for_appimage(is_linux_appimage: bool) -> bo
is_linux_appimage
}
fn map_print_result<E: std::fmt::Display>(result: Result<(), E>) -> Result<(), String> {
result.map_err(|error| format!("Failed to open the system print dialog: {error}"))
}
#[cfg(all(desktop, target_os = "linux"))]
fn linux_appimage_running() -> bool {
crate::linux_appimage::is_running()
@@ -17,13 +21,28 @@ pub fn should_use_external_media_preview() -> bool {
should_use_external_media_preview_for_appimage(linux_appimage_running())
}
#[tauri::command]
pub fn print_current_webview(window: tauri::WebviewWindow) -> Result<(), String> {
map_print_result(window.print())
}
#[cfg(test)]
mod tests {
use super::should_use_external_media_preview_for_appimage;
use super::{map_print_result, should_use_external_media_preview_for_appimage};
#[test]
fn external_media_preview_is_limited_to_linux_appimage() {
assert!(should_use_external_media_preview_for_appimage(true));
assert!(!should_use_external_media_preview_for_appimage(false));
}
#[test]
fn print_errors_are_formatted_for_the_renderer() {
let result = map_print_result::<&str>(Err("printer unavailable"));
assert_eq!(
result,
Err("Failed to open the system print dialog: printer unavailable".to_string())
);
}
}

View File

@@ -383,6 +383,16 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
crate::settings::save_settings(settings)
}
#[tauri::command]
pub fn get_ai_workspace_sessions() -> Result<serde_json::Value, String> {
crate::settings::get_ai_workspace_sessions()
}
#[tauri::command]
pub fn save_ai_workspace_sessions(sessions: serde_json::Value) -> Result<(), String> {
crate::settings::save_ai_workspace_sessions(sessions)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn check_for_app_update(

View File

@@ -100,14 +100,24 @@ pub async fn reload_vault(
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
exclude_frontmatter: Option<bool>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
let exclude_frontmatter = exclude_frontmatter.unwrap_or(false);
tokio::task::spawn_blocking(move || {
search::search_vault_with_options(search::SearchOptions {
vault_path: &vault_path,
query: &query,
mode: "keyword",
limit,
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
exclude_frontmatter,
})
})
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
#[cfg(test)]
@@ -264,7 +274,7 @@ mod tests {
let response = search_vault(
dir.path().to_string_lossy().into_owned(),
"needle".to_string(),
"keyword".to_string(),
None,
None,
)
.await
@@ -287,8 +297,8 @@ mod tests {
let response = search_vault(
dir.path().to_string_lossy().into_owned(),
"needle".to_string(),
"keyword".to_string(),
Some(1),
None,
)
.await
.unwrap();

View File

@@ -0,0 +1,57 @@
use std::io;
use std::path::Path;
use std::process::Output;
use super::git_command;
pub(super) fn git_output(dir: &Path, args: &[&str]) -> io::Result<Output> {
git_command().args(args).current_dir(dir).output()
}
pub(super) fn git_output_result(dir: &Path, args: &[&str]) -> Result<Output, String> {
git_output(dir, args).map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args)))
}
pub(super) fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
let output = git_output_result(dir, args)?;
if output.status.success() {
return Ok(());
}
Err(stderr_text(&output))
}
pub(super) fn stdout_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
pub(super) fn stdout_lines(output: &Output) -> Vec<String> {
stdout_text(output)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub(super) fn stderr_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).trim().to_string()
}
pub(super) fn stderr_or_failure(command: &str, output: &Output) -> String {
let stderr = stderr_text(output);
if stderr.is_empty() {
format!("{command} failed")
} else {
stderr
}
}
pub(super) fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
if args.first() == Some(&"-c") {
return args.get(2).copied().unwrap_or(args[0]);
}
args[0]
}

View File

@@ -89,7 +89,7 @@ fn is_commit_signing_failure(detail: &str) -> bool {
mod tests {
use super::git_command;
use super::*;
use crate::git::tests::setup_git_repo;
use crate::git::tests::{setup_git_repo, GitConfigEnvGuard};
use std::fs;
use std::path::Path;
@@ -138,6 +138,8 @@ mod tests {
#[test]
fn test_git_commit_sets_missing_local_author_identity() {
let _env = GitConfigEnvGuard::isolated();
let dir = setup_git_repo();
let vault = dir.path();
unset_local_author_config(vault);
@@ -156,7 +158,7 @@ mod tests {
);
assert_eq!(
local_config_value(vault, "user.email").as_deref(),
Some("vault@tolaria.md")
Some("vault@tolaria.default")
);
let author = git_command()
@@ -166,7 +168,39 @@ mod tests {
.unwrap();
assert_eq!(
String::from_utf8_lossy(&author.stdout).trim(),
"Tolaria <vault@tolaria.md>"
"Tolaria <vault@tolaria.default>"
);
}
#[test]
fn test_git_commit_respects_global_author_identity() {
let _env =
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
let dir = setup_git_repo();
let vault = dir.path();
unset_local_author_config(vault);
fs::write(vault.join("global-identity.md"), "# Global identity\n").unwrap();
let result = git_commit(vault.to_str().unwrap(), "Commit with global identity");
assert!(
result.is_ok(),
"commit should use the global identity: {result:?}"
);
// The global identity resolves, so no local override is written.
assert_eq!(local_config_value(vault, "user.name"), None);
assert_eq!(local_config_value(vault, "user.email"), None);
let author = git_command()
.args(["log", "-1", "--format=%an <%ae>"])
.current_dir(vault)
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&author.stdout).trim(),
"Global User <global@test.com>"
);
}

View File

@@ -129,7 +129,7 @@ pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use crate::git::tests::{setup_git_repo, setup_remote_pair, GitConfigEnvGuard};
use crate::git::{git_commit, git_pull, git_push};
use std::fs;
use std::path::Path;
@@ -310,6 +310,8 @@ mod tests {
#[test]
fn test_commit_conflict_resolution_sets_missing_local_author_identity() {
let _env = GitConfigEnvGuard::isolated();
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vault = clone_b.path();
let vp_b = vault.to_str().unwrap();
@@ -328,7 +330,7 @@ mod tests {
);
assert_eq!(
local_config_value(vault, "user.email").as_deref(),
Some("vault@tolaria.md")
Some("vault@tolaria.default")
);
}

View File

@@ -2,8 +2,12 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Output;
use super::command::{
git_output, git_output_result, run_git, stderr_text, stdout_lines, stdout_text,
};
use super::credentials::request_remote_credentials;
use super::{ensure_author_config, git_command};
use super::ensure_author_config;
use super::remote_config::{configure_origin_remote, list_configured_remotes};
const DEFAULT_REMOTE_NAME: &str = "origin";
@@ -105,7 +109,7 @@ pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result<GitAddRemote
let connection = RemoteConnection::new(branch);
let trimmed_url = remote_url.trim();
run_git(vault, &["remote", "add", DEFAULT_REMOTE_NAME, trimmed_url])?;
configure_origin_remote(vault, trimmed_url)?;
request_remote_credentials(vault, trimmed_url);
let result = finish_remote_connection(vault, &connection);
@@ -176,7 +180,7 @@ fn connect_result(status: ConnectStatus, message: impl Into<String>) -> GitAddRe
}
fn current_branch(vault: &Path) -> Result<String, String> {
let output = git_output(vault, &["branch", "--show-current"])?;
let output = git_output_result(vault, &["branch", "--show-current"])?;
if output.status.success() {
return Ok(stdout_text(&output));
@@ -186,34 +190,11 @@ fn current_branch(vault: &Path) -> Result<String, String> {
}
fn list_remotes(vault: &Path) -> Result<Vec<String>, String> {
let output = git_output(vault, &["remote"])?;
if !output.status.success() {
return Err(command_error("git remote", &output));
}
Ok(stdout_lines(&output))
list_configured_remotes(vault)
}
fn unset_upstream(vault: &Path) {
let _ = git_command()
.args(["branch", "--unset-upstream"])
.current_dir(vault)
.output();
}
fn run_git(vault: &Path, args: &[&str]) -> Result<(), String> {
let output = git_command()
.args(args)
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git {}: {e}", args[0]))?;
if output.status.success() {
return Ok(());
}
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
let _ = git_output(vault, &["branch", "--unset-upstream"]);
}
fn fetch_remote(vault: &Path) -> Result<(), String> {
@@ -221,7 +202,7 @@ fn fetch_remote(vault: &Path) -> Result<(), String> {
}
fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
let output = git_output(
let output = git_output_result(
vault,
&[
"for-each-ref",
@@ -241,17 +222,17 @@ fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
}
fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool {
git_command()
.args(["merge-base", "HEAD", connection.remote_branch.as_str()])
.current_dir(vault)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
git_output(
vault,
&["merge-base", "HEAD", connection.remote_branch.as_str()],
)
.map(|output| output.status.success())
.unwrap_or(false)
}
fn ahead_behind_counts(vault: &Path, connection: &RemoteConnection) -> Result<(u32, u32), String> {
let revision_range = format!("HEAD...{}", connection.remote_branch);
let output = git_output(
let output = git_output_result(
vault,
&["rev-list", "--left-right", "--count", &revision_range],
)?;
@@ -318,35 +299,10 @@ fn classify_connect_error(stderr: &str) -> GitAddRemoteResult {
)
}
fn git_output(vault: &Path, args: &[&str]) -> Result<Output, String> {
git_command()
.args(args)
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git {}: {e}", args[0]))
}
fn command_error(command: &str, output: &Output) -> String {
format!("{command} failed: {}", stderr_text(output))
}
fn stderr_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).trim().to_string()
}
fn stdout_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn stdout_lines(output: &Output) -> Vec<String> {
stdout_text(output)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn is_auth_error(lower: &str) -> bool {
[
"authentication failed",
@@ -387,7 +343,7 @@ fn concise_git_detail(stderr: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::setup_git_repo;
use crate::git::tests::{setup_git_repo, GitConfigEnvGuard};
use crate::git::{git_commit, git_remote_status};
use std::fs;
use std::process::Command as StdCommand;
@@ -515,6 +471,8 @@ mod tests {
#[test]
fn git_add_remote_sets_local_identity_when_existing_repo_has_none() {
let _env = GitConfigEnvGuard::isolated();
let local = setup_git_repo();
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
clear_local_author(local.path());
@@ -531,6 +489,16 @@ mod tests {
assert_eq!(result.status, "connected");
assert!(local_author_is_configured(local.path()));
let email = StdCommand::new("git")
.args(["config", "--local", "user.email"])
.current_dir(local.path())
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&email.stdout).trim(),
"vault@tolaria.default"
);
}
#[test]

View File

@@ -0,0 +1,403 @@
use std::path::Path;
use crate::vault::path_identity::vault_relative_path_string;
use super::command::{git_output, stderr_or_failure, stdout_text};
use super::remote_config::primary_remote_url;
enum RemoteWebKind {
Bitbucket,
Gitea,
GitLab,
Generic,
}
struct RemoteWebBase {
base_url: String,
kind: RemoteWebKind,
}
struct GitFileLocation {
branch: BranchName,
relative_path: RelativeGitPath,
}
struct BranchName(String);
struct RelativeGitPath(String);
struct RemoteUrl(String);
struct RemoteParts {
host: RemoteHost,
repo_path: RepoPath,
}
struct RemoteHost(String);
struct RepoPath(String);
pub fn git_file_url(vault_path: &str, file_path: &str) -> Result<Option<String>, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let Some(relative_path) = RelativeGitPath::from_paths(vault, file)? else {
return Ok(None);
};
let Some(remote_url) = primary_remote_url(vault)?.map(RemoteUrl::new) else {
return Ok(None);
};
let location = GitFileLocation::new(current_ref_name(vault)?, relative_path);
let url = match remote_web_base(&remote_url) {
Some(remote) => remote.file_url(&location),
None => remote_url.git_fragment_url(&location),
};
Ok(Some(url))
}
fn current_ref_name(vault: &Path) -> Result<BranchName, String> {
let output = git_output(vault, &["branch", "--show-current"])
.map_err(|e| format!("Failed to get branch: {e}"))?;
if !output.status.success() {
return Err(stderr_or_failure("git branch", &output));
}
let branch = stdout_text(&output);
Ok(BranchName::new(branch))
}
impl GitFileLocation {
fn new(branch: BranchName, relative_path: RelativeGitPath) -> Self {
Self {
branch,
relative_path,
}
}
}
impl BranchName {
fn new(value: String) -> Self {
if value.is_empty() {
return Self("HEAD".to_string());
}
Self(value)
}
fn encoded_fragment(&self) -> String {
encode_fragment_part(&self.0)
}
fn encoded_path(&self) -> String {
encode_path(&self.0)
}
}
impl RelativeGitPath {
fn from_paths(vault: &Path, file: &Path) -> Result<Option<Self>, String> {
let value = vault_relative_path_string(vault, file)?;
if value.is_empty() {
return Ok(None);
}
Ok(Some(Self(value)))
}
fn encoded_fragment(&self) -> String {
encode_fragment_part(&self.0)
}
fn encoded_path(&self) -> String {
encode_path(&self.0)
}
}
impl RemoteWebBase {
fn file_url(&self, location: &GitFileLocation) -> String {
let branch = location.branch.encoded_path();
let path = location.relative_path.encoded_path();
match self.kind {
RemoteWebKind::Bitbucket => format!("{}/src/{}/{}", self.base_url, branch, path),
RemoteWebKind::Gitea => format!("{}/src/branch/{}/{}", self.base_url, branch, path),
RemoteWebKind::GitLab => format!("{}/-/blob/{}/{}", self.base_url, branch, path),
RemoteWebKind::Generic => format!("{}/blob/{}/{}", self.base_url, branch, path),
}
}
}
impl RemoteUrl {
fn new(value: String) -> Self {
Self(value)
}
fn trimmed(&self) -> &str {
self.0.trim()
}
fn git_fragment_url(&self, location: &GitFileLocation) -> String {
format!(
"{}#{}:{}",
self.trimmed(),
location.branch.encoded_fragment(),
location.relative_path.encoded_fragment(),
)
}
fn host_and_path(&self) -> Option<RemoteParts> {
self.http_parts()
.or_else(|| self.scheme_parts())
.or_else(|| self.scp_parts())
}
fn http_parts(&self) -> Option<RemoteParts> {
let rest = self
.trimmed()
.strip_prefix("https://")
.or_else(|| self.trimmed().strip_prefix("http://"))?;
RemoteParts::from_authority_path(rest)
}
fn scheme_parts(&self) -> Option<RemoteParts> {
let rest = self
.trimmed()
.strip_prefix("ssh://")
.or_else(|| self.trimmed().strip_prefix("git://"))?;
RemoteParts::from_authority_path(rest)
}
fn scp_parts(&self) -> Option<RemoteParts> {
let (_, target) = self.trimmed().split_once('@')?;
let (host, path) = target.split_once(':')?;
Some(RemoteParts::new(RemoteHost::new(host), RepoPath::new(path)))
}
}
impl RemoteParts {
fn new(host: RemoteHost, repo_path: RepoPath) -> Self {
Self { host, repo_path }
}
fn from_authority_path(value: &str) -> Option<Self> {
let (authority, path) = value.split_once('/')?;
Some(Self::new(
RemoteHost::from_authority(authority),
RepoPath::new(path),
))
}
fn into_web_base(self) -> Option<RemoteWebBase> {
let clean_path = self.repo_path.clean();
if self.host.is_empty() {
return None;
}
if clean_path.is_empty() {
return None;
}
let base_url = format!("https://{}/{clean_path}", self.host.as_str());
Some(RemoteWebBase {
kind: remote_web_kind(&self.host),
base_url,
})
}
}
impl RemoteHost {
fn new(value: &str) -> Self {
Self(value.to_string())
}
fn from_authority(authority: &str) -> Self {
Self::new(authority.rsplit('@').next().unwrap_or_default())
}
fn as_str(&self) -> &str {
&self.0
}
fn is_empty(&self) -> bool {
self.0.is_empty()
}
fn lower(&self) -> String {
self.0.to_ascii_lowercase()
}
}
impl RepoPath {
fn new(value: &str) -> Self {
Self(value.to_string())
}
fn clean(&self) -> &str {
let trimmed = self.0.trim_matches('/');
trimmed.strip_suffix(".git").unwrap_or(trimmed)
}
}
fn remote_web_base(remote_url: &RemoteUrl) -> Option<RemoteWebBase> {
remote_url.host_and_path()?.into_web_base()
}
fn remote_web_kind(host: &RemoteHost) -> RemoteWebKind {
let lower_host = host.lower();
if lower_host.contains("gitlab") {
return RemoteWebKind::GitLab;
}
if lower_host.contains("bitbucket") {
return RemoteWebKind::Bitbucket;
}
if lower_host.contains("gitea") {
return RemoteWebKind::Gitea;
}
if lower_host.contains("forgejo") {
return RemoteWebKind::Gitea;
}
if lower_host == "codeberg.org" {
return RemoteWebKind::Gitea;
}
RemoteWebKind::Generic
}
fn encode_path(path: &str) -> String {
path.split('/')
.map(encode_segment)
.collect::<Vec<_>>()
.join("/")
}
fn encode_fragment_part(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b' ' => encoded.push_str("%20"),
b'#' => encoded.push_str("%23"),
b'%' => encoded.push_str("%25"),
_ => encoded.push(char::from(byte)),
}
}
encoded
}
fn encode_segment(segment: &str) -> String {
segment
.bytes()
.flat_map(|byte| {
if is_unreserved_url_byte(byte) {
vec![byte]
} else {
format!("%{byte:02X}").into_bytes()
}
})
.map(char::from)
.collect()
}
fn is_unreserved_url_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_command;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::path::Path;
struct RemoteFixture {
name: &'static str,
url: &'static str,
}
struct GitUrlCase {
remote: RemoteFixture,
note_path: &'static str,
expected_url: &'static str,
}
fn add_remote(vault: &Path, remote: RemoteFixture) {
git_command()
.args(["remote", "add", remote.name, remote.url])
.current_dir(vault)
.output()
.unwrap();
}
fn write_note(vault: &Path, relative_path: &str) -> String {
let file = vault.join(relative_path);
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(&file, "# Note\n").unwrap();
file.to_string_lossy().to_string()
}
fn assert_git_file_url(test_case: GitUrlCase) {
let dir = setup_git_repo();
let note = write_note(dir.path(), test_case.note_path);
add_remote(dir.path(), test_case.remote);
let url = git_file_url(dir.path().to_str().unwrap(), &note).unwrap();
assert_eq!(url.as_deref(), Some(test_case.expected_url));
}
#[test]
fn returns_none_without_remote() {
let dir = setup_git_repo();
let note = write_note(dir.path(), "note.md");
let url = git_file_url(dir.path().to_str().unwrap(), &note).unwrap();
assert_eq!(url, None);
}
#[test]
fn returns_none_outside_git_repository() {
let dir = tempfile::tempdir().unwrap();
let note = write_note(dir.path(), "note.md");
let url = git_file_url(dir.path().to_str().unwrap(), &note).unwrap();
assert_eq!(url, None);
}
#[test]
fn builds_remote_note_urls() {
[
GitUrlCase {
remote: RemoteFixture {
name: "origin",
url: "git@github.com:owner/repo.git",
},
note_path: "Notes/Project Plan.md",
expected_url: "https://github.com/owner/repo/blob/main/Notes/Project%20Plan.md",
},
GitUrlCase {
remote: RemoteFixture {
name: "origin",
url: "https://gho_secret@github.com/owner/repo.git",
},
note_path: "private.md",
expected_url: "https://github.com/owner/repo/blob/main/private.md",
},
GitUrlCase {
remote: RemoteFixture {
name: "origin",
url: "https://gitlab.com/group/repo.git",
},
note_path: "notes/topic.md",
expected_url: "https://gitlab.com/group/repo/-/blob/main/notes/topic.md",
},
GitUrlCase {
remote: RemoteFixture {
name: "upstream",
url: "https://github.com/team/vault.git",
},
note_path: "shared.md",
expected_url: "https://github.com/team/vault/blob/main/shared.md",
},
]
.into_iter()
.for_each(assert_git_file_url);
}
}

View File

@@ -1,12 +1,15 @@
mod clone;
mod command;
mod commit;
mod conflict;
mod connect;
mod credentials;
mod dates;
mod file_url;
mod history;
mod pulse;
mod remote;
mod remote_config;
mod status;
use std::ffi::{OsStr, OsString};
@@ -14,6 +17,9 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
#[cfg(test)]
use std::cell::RefCell;
pub use clone::clone_repo;
pub use commit::git_commit;
pub use conflict::{
@@ -22,6 +28,7 @@ pub use conflict::{
};
pub use connect::{disconnect_all_remotes, git_add_remote, GitAddRemoteResult};
pub use dates::{get_all_file_dates, GitDates};
pub use file_url::git_file_url;
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
pub use remote::{
@@ -80,10 +87,34 @@ pub(crate) fn git_command() -> Command {
command.env("PATH", path);
}
sanitize_linux_appimage_git_env(&mut command);
#[cfg(test)]
apply_test_git_config_env(&mut command);
command.args(["-c", "core.quotePath=false"]);
command
}
#[cfg(test)]
#[derive(Clone)]
struct TestGitConfigEnv {
global: PathBuf,
system: PathBuf,
}
#[cfg(test)]
thread_local! {
static TEST_GIT_CONFIG_ENV: RefCell<Option<TestGitConfigEnv>> = const { RefCell::new(None) };
}
#[cfg(test)]
fn apply_test_git_config_env(command: &mut Command) {
TEST_GIT_CONFIG_ENV.with(|env| {
if let Some(config) = env.borrow().as_ref() {
command.env("GIT_CONFIG_GLOBAL", &config.global);
command.env("GIT_CONFIG_SYSTEM", &config.system);
}
});
}
fn git_launch_config() -> &'static GitLaunchConfig {
static CONFIG: OnceLock<GitLaunchConfig> = OnceLock::new();
CONFIG.get_or_init(detect_git_launch_config)
@@ -267,11 +298,12 @@ fn commit_initial_vault_setup(dir: &Path) -> Result<(), String> {
/// Run a git command in the given directory, returning an error on failure.
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
let output = git_command()
.args(args)
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args)))?;
let output = command::git_output(dir, args).map_err(|e| {
format!(
"Failed to run git {}: {e}",
command::git_command_label(args)
)
})?;
if output.status.success() {
return Ok(());
@@ -279,30 +311,49 @@ fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
Err(format!(
"git {} failed: {}",
git_command_label(args),
command::git_command_label(args),
String::from_utf8_lossy(&output.stderr)
))
}
fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
if args.first() == Some(&"-c") {
return args.get(2).copied().unwrap_or(args[0]);
}
/// Fallback author name written when no identity is configured anywhere.
const FALLBACK_AUTHOR_NAME: &str = "Tolaria";
args[0]
}
/// Fallback author email written when no identity is configured anywhere.
const FALLBACK_AUTHOR_EMAIL: &str = "vault@tolaria.default";
/// Set local user.name and user.email if not already configured.
/// Email previously hardcoded by Tolaria. GitHub may attribute it to a real
/// account, so it is treated as "no identity": healed from the local scope and
/// skipped wherever it resolves from.
const LEGACY_FALLBACK_EMAIL: &str = "vault@tolaria.md";
/// Ensure git can resolve an author identity for the vault, without ever
/// overriding one the user configured themselves.
///
/// 1. Heal: earlier Tolaria versions unconditionally wrote
/// `Tolaria <vault@tolaria.md>` into the repo-local config, shadowing the
/// user's own global/system identity. If that legacy email is still present
/// locally, remove it so the user's identity resolves again.
/// 2. Respect: if git resolves a value from any scope, keep it. A resolved email
/// equal to the legacy fallback is skipped, since it misattributes commits.
/// 3. Fallback: only when nothing resolves, write a repo-local Tolaria fallback
/// identity so app-managed commits still work.
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [("user.name", "Tolaria"), ("user.email", "vault@tolaria.md")] {
let local = git_command()
.args(["config", "--local", key])
heal_legacy_local_identity(dir)?;
for (key, fallback, skip_legacy) in [
("user.name", FALLBACK_AUTHOR_NAME, false),
("user.email", FALLBACK_AUTHOR_EMAIL, true),
] {
let resolved = git_command()
.args(["config", key])
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to check git config {key}: {e}"))?;
let value = String::from_utf8_lossy(&local.stdout);
if local.status.success() && !value.trim().is_empty() {
let value = String::from_utf8_lossy(&resolved.stdout);
let value = value.trim();
if resolved.status.success() && resolved_author_value_is_usable(value, skip_legacy) {
continue;
}
@@ -311,6 +362,42 @@ pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
Ok(())
}
fn resolved_author_value_is_usable(value: &str, skip_legacy: bool) -> bool {
if value.is_empty() {
return false;
}
!skip_legacy || value != LEGACY_FALLBACK_EMAIL
}
/// Remove the local `vault@tolaria.md` email that earlier versions wrote into
/// repo-local config. A local name the user set themselves is left untouched.
fn heal_legacy_local_identity(dir: &Path) -> Result<(), String> {
let local_email = local_config_value(dir, "user.email")?;
if local_email.as_deref() != Some(LEGACY_FALLBACK_EMAIL) {
return Ok(());
}
run_git(dir, &["config", "--local", "--unset-all", "user.email"])?;
if local_config_value(dir, "user.name")?.as_deref() == Some(FALLBACK_AUTHOR_NAME) {
run_git(dir, &["config", "--local", "--unset-all", "user.name"])?;
}
Ok(())
}
/// Read a repo-local config value, or `None` when it is not set.
fn local_config_value(dir: &Path, key: &str) -> Result<Option<String>, String> {
let output = git_command()
.args(["config", "--local", key])
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to check git config {key}: {e}"))?;
let value = String::from_utf8_lossy(&output.stdout);
let value = value.trim();
Ok((output.status.success() && !value.is_empty()).then(|| value.to_string()))
}
/// Extract "owner/repo" from a GitHub remote URL.
/// Supports HTTPS (https://github.com/owner/repo.git) and
/// SSH (git@github.com:owner/repo.git) formats.
@@ -345,6 +432,52 @@ mod tests {
use std::fs;
use tempfile::TempDir;
/// Redirect global and system git config to files under a TempDir so
/// identity tests are hermetic with respect to the developer's own
/// gitconfig.
pub(crate) struct GitConfigEnvGuard {
previous: Option<TestGitConfigEnv>,
_dir: TempDir,
}
impl GitConfigEnvGuard {
/// No identity resolvable outside the repo's local config.
pub(crate) fn isolated() -> Self {
Self::with_global_identity(None)
}
/// Optionally expose a global identity to spawned git commands.
pub(crate) fn with_global_identity(identity: Option<(&str, &str)>) -> Self {
let dir = TempDir::new().unwrap();
let global = dir.path().join("gitconfig-global");
if let Some((name, email)) = identity {
fs::write(
&global,
format!("[user]\n\tname = {name}\n\temail = {email}\n"),
)
.unwrap();
}
let system = dir.path().join("gitconfig-system");
let config = TestGitConfigEnv { global, system };
let previous = TEST_GIT_CONFIG_ENV.with(|env| env.replace(Some(config)));
Self {
previous,
_dir: dir,
}
}
}
impl Drop for GitConfigEnvGuard {
fn drop(&mut self) {
let previous = self.previous.take();
TEST_GIT_CONFIG_ENV.with(|env| {
env.replace(previous);
});
}
}
fn assert_repo_path(url: &str, expected: Option<&str>) {
assert_eq!(
parse_github_repo_path(url),
@@ -425,6 +558,160 @@ mod tests {
(bare_dir, clone_a_dir, clone_b_dir)
}
fn init_plain_repo() -> TempDir {
let dir = TempDir::new().unwrap();
git_command()
.args(["init", "--initial-branch=main"])
.current_dir(dir.path())
.output()
.unwrap();
dir
}
fn set_local_identity(dir: &Path, name: &str, email: &str) {
for (key, value) in [("user.name", name), ("user.email", email)] {
git_command()
.args(["config", "--local", key, value])
.current_dir(dir)
.output()
.unwrap();
}
}
fn assert_local_identity(dir: &Path, name: Option<&str>, email: Option<&str>) {
assert_eq!(
local_config_value(dir, "user.name").unwrap().as_deref(),
name
);
assert_eq!(
local_config_value(dir, "user.email").unwrap().as_deref(),
email
);
}
#[test]
fn test_ensure_author_config_respects_existing_global_identity() {
let _env =
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
let dir = init_plain_repo();
ensure_author_config(dir.path()).unwrap();
// The globally configured identity resolves, so no local override
// should be written.
assert_local_identity(dir.path(), None, None);
}
#[test]
fn test_ensure_author_config_sets_fallback_without_any_identity() {
let _env = GitConfigEnvGuard::isolated();
let dir = init_plain_repo();
ensure_author_config(dir.path()).unwrap();
assert_local_identity(
dir.path(),
Some(FALLBACK_AUTHOR_NAME),
Some(FALLBACK_AUTHOR_EMAIL),
);
}
#[test]
fn test_ensure_author_config_heals_legacy_identity_when_global_exists() {
let _env =
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
let dir = init_plain_repo();
set_local_identity(dir.path(), FALLBACK_AUTHOR_NAME, LEGACY_FALLBACK_EMAIL);
ensure_author_config(dir.path()).unwrap();
// The legacy pair is removed so the global identity resolves again.
assert_local_identity(dir.path(), None, None);
}
#[test]
fn test_ensure_author_config_replaces_legacy_identity_without_global() {
let _env = GitConfigEnvGuard::isolated();
let dir = init_plain_repo();
set_local_identity(dir.path(), FALLBACK_AUTHOR_NAME, LEGACY_FALLBACK_EMAIL);
ensure_author_config(dir.path()).unwrap();
// No user identity anywhere: the legacy email is replaced with the
// fallback so commits keep working.
assert_local_identity(
dir.path(),
Some(FALLBACK_AUTHOR_NAME),
Some(FALLBACK_AUTHOR_EMAIL),
);
}
#[test]
fn test_ensure_author_config_keeps_user_set_local_identity() {
let _env = GitConfigEnvGuard::isolated();
let dir = init_plain_repo();
set_local_identity(dir.path(), "Vault Owner", "owner@example.com");
ensure_author_config(dir.path()).unwrap();
// A local identity the user set themselves is never touched.
assert_local_identity(dir.path(), Some("Vault Owner"), Some("owner@example.com"));
}
#[test]
fn test_ensure_author_config_preserves_user_name_when_healing_legacy_email() {
let _env = GitConfigEnvGuard::isolated();
let dir = init_plain_repo();
set_local_identity(dir.path(), "Vault Owner", LEGACY_FALLBACK_EMAIL);
ensure_author_config(dir.path()).unwrap();
assert_local_identity(dir.path(), Some("Vault Owner"), Some(FALLBACK_AUTHOR_EMAIL));
}
#[test]
fn test_ensure_author_config_skips_legacy_email_resolved_from_global() {
let _env =
GitConfigEnvGuard::with_global_identity(Some(("Someone", LEGACY_FALLBACK_EMAIL)));
let dir = init_plain_repo();
ensure_author_config(dir.path()).unwrap();
// The name resolves globally; the legacy email is skipped and the
// fallback is written locally instead.
assert_local_identity(dir.path(), None, Some(FALLBACK_AUTHOR_EMAIL));
}
#[test]
fn test_init_repo_respects_global_author_identity_for_initial_commit() {
let _env =
GitConfigEnvGuard::with_global_identity(Some(("Global User", "global@test.com")));
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
init_repo(dir.path()).unwrap();
assert_local_identity(dir.path(), None, None);
let author = git_command()
.args(["log", "-1", "--format=%an <%ae>"])
.current_dir(dir.path())
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&author.stdout).trim(),
"Global User <global@test.com>"
);
}
fn command_envs(command: &Command) -> HashMap<String, Option<String>> {
command
.get_envs()

View File

@@ -1,15 +1,12 @@
use super::git_command;
use serde::{Deserialize, Serialize};
use std::path::Path;
use super::command::{git_output, git_output_result, stderr_text, stdout_text};
use super::conflict::get_conflict_files;
use super::remote_config::has_configured_remote;
const GIT_CONFIG_SUBCOMMAND: &str = "config";
const GIT_CONFIG_GET_REGEXP: &str = "--get-regexp";
const REMOTE_URL_CONFIG_PATTERN: &str = r"^remote\..*\.url$";
const NO_REMOTE_STATUS: &str = "no_remote";
const NO_REMOTE_MESSAGE: &str = "No remote configured";
const GIT_INSPECT_REMOTES_ERROR: &str = "Failed to inspect git remotes";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitPullResult {
@@ -24,33 +21,7 @@ pub struct GitPullResult {
/// Check whether the vault repo has at least one remote configured.
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
let vault = Path::new(vault_path);
remote_url_config_exists(vault)
}
fn remote_url_config_exists(vault: &Path) -> Result<bool, String> {
let output = git_command()
.args([
GIT_CONFIG_SUBCOMMAND,
GIT_CONFIG_GET_REGEXP,
REMOTE_URL_CONFIG_PATTERN,
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to inspect git remotes: {}", e))?;
if !output.status.success() {
if output.status.code() == Some(1) {
return Ok(false);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if stderr.is_empty() {
GIT_INSPECT_REMOTES_ERROR.to_string()
} else {
stderr
});
}
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
has_configured_remote(vault)
}
/// Pull latest changes from remote. Uses --no-rebase to merge.
@@ -67,14 +38,11 @@ pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
});
}
let output = git_command()
.args(["pull", "--no-rebase"])
.current_dir(vault)
.output()
let output = git_output(vault, &["pull", "--no-rebase"])
.map_err(|e| format!("Failed to run git pull: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let stdout = stdout_text(&output);
let stderr = stderr_text(&output);
if output.status.success() {
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
@@ -161,18 +129,14 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
}
// Fetch latest remote refs (silent, best-effort)
let _ = git_command()
.args(["fetch", "--quiet"])
.current_dir(vault)
.output();
let _ = git_output(vault, &["fetch", "--quiet"]);
let branch = current_branch(vault)?;
let output = git_command()
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git rev-list: {}", e))?;
let output = git_output_result(
vault,
&["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
)?;
if !output.status.success() {
// No upstream set — report 0/0
@@ -184,8 +148,8 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = stdout.trim().split('\t').collect();
let stdout = stdout_text(&output);
let parts: Vec<&str> = stdout.split('\t').collect();
let ahead = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let behind = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
@@ -198,12 +162,9 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
}
fn current_branch(vault: &Path) -> Result<String, String> {
let output = git_command()
.args(["branch", "--show-current"])
.current_dir(vault)
.output()
let output = git_output(vault, &["branch", "--show-current"])
.map_err(|e| format!("Failed to get branch: {}", e))?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
Ok(stdout_text(&output))
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
@@ -333,14 +294,11 @@ pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
});
}
let output = git_command()
.args(["push"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git push: {}", e))?;
let output =
git_output(vault, &["push"]).map_err(|e| format!("Failed to run git push: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr = stderr_text(&output);
return Ok(classify_push_error(&stderr));
}
@@ -353,6 +311,7 @@ pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_command;
use crate::git::git_commit;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use std::fs;

View File

@@ -0,0 +1,88 @@
use std::path::Path;
use super::command::{git_output, stderr_or_failure, stdout_lines};
const DEFAULT_FETCH_REFSPEC: &str = "+refs/heads/*:refs/remotes/origin/*";
const REMOTE_URL_CONFIG_PATTERN: &str = r"^remote\..*\.url$";
const ORIGIN_URL_CONFIG_KEY: &str = "remote.origin.url";
const ORIGIN_FETCH_CONFIG_KEY: &str = "remote.origin.fetch";
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfiguredRemote {
name: String,
url: String,
}
pub(super) fn has_configured_remote(vault: &Path) -> Result<bool, String> {
Ok(!list_configured_remotes(vault)?.is_empty())
}
pub(super) fn list_configured_remotes(vault: &Path) -> Result<Vec<String>, String> {
Ok(list_configured_remote_urls(vault)?
.into_iter()
.map(|remote| remote.name)
.collect())
}
pub(super) fn primary_remote_url(vault: &Path) -> Result<Option<String>, String> {
let remotes = list_configured_remote_urls(vault)?;
Ok(remotes
.iter()
.find(|remote| remote.name == "origin")
.or_else(|| remotes.first())
.map(|remote| remote.url.clone()))
}
fn list_configured_remote_urls(vault: &Path) -> Result<Vec<ConfiguredRemote>, String> {
let output = git_output(
vault,
&["config", "--get-regexp", REMOTE_URL_CONFIG_PATTERN],
)
.map_err(|e| format!("Failed to inspect git remotes: {e}"))?;
if output.status.code() == Some(1) {
return Ok(Vec::new());
}
if !output.status.success() {
return Err(stderr_or_failure("git config --get-regexp", &output));
}
Ok(stdout_lines(&output)
.into_iter()
.filter_map(|line| remote_from_url_config(&line))
.collect())
}
fn remote_from_url_config(line: &str) -> Option<ConfiguredRemote> {
let (key, value) = line.split_once(' ')?;
let url = value.trim();
if url.is_empty() {
return None;
}
let name = key
.strip_prefix("remote.")
.and_then(|name| name.strip_suffix(".url"))
.filter(|name| !name.is_empty())
.map(ToString::to_string)?;
Some(ConfiguredRemote {
name,
url: url.to_string(),
})
}
pub(super) fn configure_origin_remote(vault: &Path, remote_url: &str) -> Result<(), String> {
run_git_config(vault, ORIGIN_URL_CONFIG_KEY, remote_url)?;
run_git_config(vault, ORIGIN_FETCH_CONFIG_KEY, DEFAULT_FETCH_REFSPEC)
}
fn run_git_config(vault: &Path, key: &str, value: &str) -> Result<(), String> {
let output = git_output(vault, &["config", "--local", "--replace-all", key, value])
.map_err(|e| format!("Failed to run git config: {e}"))?;
if output.status.success() {
return Ok(());
}
Err(stderr_or_failure("git config", &output))
}

View File

@@ -23,6 +23,12 @@ struct DiffStats {
binary: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StatusEntry {
status_code: String,
relative_path: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileChangeStatus {
Modified,
@@ -54,31 +60,63 @@ impl FileChangeStatus {
}
}
fn parse_status_path(raw_path: &str) -> String {
raw_path
.split(" -> ")
.last()
.unwrap_or(raw_path)
.trim()
.to_string()
fn split_nul_fields(output: &[u8]) -> Vec<String> {
output
.split(|byte| *byte == 0)
.filter(|field| !field.is_empty())
.map(|field| String::from_utf8_lossy(field).into_owned())
.collect()
}
fn status_has_source_path(status_code: &str) -> bool {
status_code.contains('R') || status_code.contains('C')
}
fn parse_status_field(field: &str) -> Option<StatusEntry> {
if field.len() < 4 {
return None;
}
Some(StatusEntry {
status_code: field[..2].to_string(),
relative_path: field[3..].to_string(),
})
}
fn parse_status_output(output: &[u8]) -> Vec<StatusEntry> {
let fields = split_nul_fields(output);
let mut entries = Vec::new();
let mut index = 0;
while index < fields.len() {
let Some(entry) = parse_status_field(&fields[index]) else {
index += 1;
continue;
};
let has_source_path = status_has_source_path(&entry.status_code);
entries.push(entry);
index += if has_source_path { 2 } else { 1 };
}
entries
}
fn parse_numstat_field(field: &str) -> Option<usize> {
field.parse().ok()
}
fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 3 {
return None;
}
fn parse_numstat_header(header: &str) -> Option<(Option<String>, DiffStats)> {
let mut parts = header.splitn(3, '\t');
let added = parts.next()?;
let deleted = parts.next()?;
let path = parts.next()?;
let added_lines = parse_numstat_field(parts[0]);
let deleted_lines = parse_numstat_field(parts[1]);
let binary = parts[0] == "-" || parts[1] == "-";
let added_lines = parse_numstat_field(added);
let deleted_lines = parse_numstat_field(deleted);
let binary = added == "-" || deleted == "-";
Some((
parts.last()?.trim().to_string(),
(!path.is_empty()).then(|| path.to_string()),
DiffStats {
added_lines,
deleted_lines,
@@ -87,6 +125,35 @@ fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> {
))
}
fn parse_numstat_output(output: &[u8]) -> HashMap<String, DiffStats> {
let fields = split_nul_fields(output);
let mut stats = HashMap::new();
let mut index = 0;
while index < fields.len() {
let Some((path, diff_stats)) = parse_numstat_header(&fields[index]) else {
index += 1;
continue;
};
match path {
Some(path) => {
stats.insert(path, diff_stats);
index += 1;
}
None if index + 2 < fields.len() => {
stats.insert(fields[index + 2].clone(), diff_stats);
index += 3;
}
None => {
index += 1;
}
}
}
stats
}
fn repo_has_head(vault: &Path) -> Result<bool, String> {
let output = git_command()
.args(["rev-parse", "--verify", "HEAD"])
@@ -103,7 +170,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
}
let output = git_command()
.args(["diff", "--numstat", "--find-renames", "HEAD", "--"])
.args(["diff", "--numstat", "-z", "--find-renames", "HEAD", "--"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff --numstat: {e}"))?;
@@ -113,11 +180,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
return Err(format!("git diff --numstat failed: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.filter_map(parse_numstat_line)
.collect::<HashMap<_, _>>())
Ok(parse_numstat_output(&output.stdout))
}
fn count_worktree_lines(vault: &Path, relative_path: &Path) -> DiffStats {
@@ -229,7 +292,7 @@ pub fn get_modified_files_with_stats(
fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result<Vec<ModifiedFile>, String> {
let output = git_command()
.args(["status", "--porcelain", "--untracked-files=all"])
.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {e}"))?;
@@ -244,27 +307,22 @@ fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result<Vec<Modi
} else {
HashMap::new()
};
let stdout = String::from_utf8_lossy(&output.stdout);
let files = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
if line.len() < 4 {
return None;
}
let status_code = &line[..2];
let relative_path = parse_status_path(&line[3..]);
let files = parse_status_output(&output.stdout)
.into_iter()
.filter_map(|entry| {
// Only include markdown files
if !relative_path.ends_with(".md") {
if !entry.relative_path.ends_with(".md") {
return None;
}
let status = FileChangeStatus::from_code(status_code);
let full_path = vault.join(&relative_path).to_string_lossy().to_string();
let status = FileChangeStatus::from_code(&entry.status_code);
let full_path = vault
.join(&entry.relative_path)
.to_string_lossy()
.to_string();
let stats = resolve_diff_stats(
vault,
Path::new(&relative_path),
Path::new(&entry.relative_path),
status,
&diff_stats,
include_stats,
@@ -272,7 +330,7 @@ fn get_modified_files_impl(vault: &Path, include_stats: bool) -> Result<Vec<Modi
Some(ModifiedFile {
path: full_path,
relative_path,
relative_path: entry.relative_path,
status: status.label().to_string(),
added_lines: stats.added_lines,
deleted_lines: stats.deleted_lines,
@@ -333,6 +391,32 @@ mod tests {
.unwrap();
}
fn expect_modified_file(vp: &str, relative_path: &str, status: &str) -> ModifiedFile {
let modified = get_modified_files_with_stats(vp).unwrap();
let file = modified
.iter()
.find(|file| file.relative_path == relative_path)
.unwrap_or_else(|| panic!("{relative_path} should be reported as {status}"));
assert_eq!(file.status, status);
assert!(file.path.ends_with(relative_path));
file.clone()
}
fn expect_changed_file_after(
relative_path: &str,
status: &str,
change: impl FnOnce(&Path, &str),
) -> ModifiedFile {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
change(vault, vp);
expect_modified_file(vp, relative_path, status)
}
#[test]
fn test_get_modified_files_with_stats() {
let dir = setup_git_repo();
@@ -443,26 +527,54 @@ 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_with_stats(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));
let file = expect_changed_file_after(relative_path, "modified", |vault, vp| {
force_quoted_git_paths(vault);
write_and_commit_markdown(vault, vp, relative_path, "# 初始\n");
fs::write(vault.join(relative_path), "# 初始\n\n更新\n").unwrap();
});
assert_eq!(file.added_lines, Some(2));
}
#[test]
fn test_get_modified_files_preserves_untracked_markdown_path_with_spaces() {
let relative_path = "test note.md";
let file = expect_changed_file_after(relative_path, "untracked", |vault, vp| {
write_and_commit_markdown(vault, vp, "init.md", "# Init\n");
fs::write(vault.join(relative_path), "# Test\n").unwrap();
});
assert_eq!(file.added_lines, Some(1));
}
#[test]
fn test_get_modified_files_preserves_modified_markdown_path_with_spaces() {
let relative_path = "test note.md";
let file = expect_changed_file_after(relative_path, "modified", |vault, vp| {
write_and_commit_markdown(vault, vp, relative_path, "# Test\n");
fs::write(vault.join(relative_path), "# Test\n\nUpdated\n").unwrap();
});
assert_eq!(file.added_lines, Some(2));
}
#[test]
fn test_get_modified_files_preserves_renamed_markdown_path_with_spaces() {
let relative_path = "test note.md";
let file = expect_changed_file_after(relative_path, "renamed", |vault, vp| {
write_and_commit_markdown(vault, vp, "alpha.md", "# Alpha\n");
git_command()
.args(["mv", "alpha.md", relative_path])
.current_dir(vault)
.output()
.unwrap();
});
assert_eq!(file.added_lines, Some(0));
assert_eq!(file.deleted_lines, Some(0));
}
#[test]
fn test_commit_flow_modified_files_then_commit_clears() {
let dir = setup_git_repo();

View File

@@ -1,4 +1,5 @@
pub mod ai_agents;
mod ai_model_tools;
pub mod ai_models;
mod app_icon;
pub mod app_updater;
@@ -278,9 +279,44 @@ fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::
Ok(())
}
#[cfg(desktop)]
fn focus_main_window(app_handle: &tauri::AppHandle) {
use tauri::Manager;
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}
#[cfg(desktop)]
fn with_desktop_entry_plugins(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry> {
builder
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
focus_main_window(app);
}))
.plugin(tauri_plugin_deep_link::init())
}
#[cfg(desktop)]
fn setup_deep_link_runtime_registration(
_app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(any(target_os = "windows", target_os = "linux"))]
{
use tauri_plugin_deep_link::DeepLinkExt;
_app.deep_link().register_all()?;
}
Ok(())
}
#[cfg(desktop)]
fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
setup_macos_webview_shortcut_prevention(app)?;
setup_deep_link_runtime_registration(app)?;
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
@@ -465,6 +501,7 @@ macro_rules! app_invoke_handler {
commands::git_pull,
commands::git_push,
commands::git_remote_status,
commands::git_file_url,
commands::git_add_remote,
commands::get_conflict_files,
commands::get_conflict_mode,
@@ -500,6 +537,7 @@ macro_rules! app_invoke_handler {
commands::delete_vault_folder,
commands::batch_archive_notes,
commands::get_settings,
commands::get_ai_workspace_sessions,
commands::check_for_app_update,
commands::update_menu_state,
commands::update_app_icon,
@@ -507,6 +545,7 @@ macro_rules! app_invoke_handler {
commands::update_current_window_min_size,
commands::perform_current_window_titlebar_double_click,
commands::save_settings,
commands::save_ai_workspace_sessions,
commands::download_and_install_app_update,
commands::load_vault_list,
commands::save_vault_list,
@@ -527,6 +566,9 @@ macro_rules! app_invoke_handler {
commands::repair_vault,
commands::reinit_telemetry,
commands::should_use_external_media_preview,
commands::print_current_webview,
commands::can_export_current_webview_pdf,
commands::export_current_webview_pdf,
commands::list_views,
commands::save_view_cmd,
commands::delete_view_cmd,
@@ -560,6 +602,9 @@ pub fn run() {
let builder = tauri::Builder::default();
#[cfg(desktop)]
let builder = with_desktop_entry_plugins(builder);
#[cfg(desktop)]
let builder = builder
.manage(WsBridgeChild(Mutex::new(None)))

View File

@@ -1,4 +1,5 @@
use std::path::PathBuf;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
let local_app_data = if cfg!(windows) {
@@ -6,24 +7,30 @@ pub(super) fn runtime_resource_roots() -> Vec<PathBuf> {
} else {
None
};
let current_exe = std::env::current_exe().ok();
runtime_resource_roots_for_env(
runtime_resource_roots_for_env_and_exe(
non_empty_env_path("RESOURCEPATH"),
non_empty_env_path("APPDIR"),
local_app_data,
current_exe.as_deref(),
)
}
fn runtime_resource_roots_for_env(
fn runtime_resource_roots_for_env_and_exe(
resource_path: Option<PathBuf>,
appdir: Option<PathBuf>,
local_app_data: Option<PathBuf>,
current_exe: Option<&Path>,
) -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Some(resource_path) = resource_path {
push_resource_root(&mut roots, resource_path);
}
if let Some(current_exe) = current_exe {
push_current_exe_resource_roots(&mut roots, current_exe);
}
if let Some(appdir) = appdir {
push_resource_root(&mut roots, appdir.join("usr"));
push_resource_root(&mut roots, appdir.join("usr/lib/tolaria"));
@@ -37,6 +44,37 @@ fn runtime_resource_roots_for_env(
roots
}
fn push_current_exe_resource_roots(roots: &mut Vec<PathBuf>, current_exe: &Path) {
let Some(exe_dir) = current_exe.parent() else {
return;
};
push_resource_root(roots, exe_dir.to_path_buf());
push_resource_root(roots, exe_dir.join("resources"));
if let Some(resource_dir) = macos_app_resources_dir(current_exe) {
push_resource_root(roots, resource_dir);
}
}
fn macos_app_resources_dir(executable: &Path) -> Option<PathBuf> {
let macos_dir = executable.parent()?;
if macos_dir.file_name() != Some(OsStr::new("MacOS")) {
return None;
}
let contents_dir = macos_dir.parent()?;
if contents_dir.file_name() != Some(OsStr::new("Contents")) {
return None;
}
let app_dir = contents_dir.parent()?;
if app_dir.extension() != Some(OsStr::new("app")) {
return None;
}
Some(contents_dir.join("Resources"))
}
fn push_resource_root(roots: &mut Vec<PathBuf>, root: PathBuf) {
if !root.as_os_str().is_empty() && !roots.iter().any(|candidate| candidate == &root) {
roots.push(root);
@@ -52,13 +90,13 @@ fn non_empty_env_path(key: &str) -> Option<PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn includes_windows_install_locations() {
let local_app_data = PathBuf::from(r"C:\Users\alex\AppData\Local");
let install_dir = local_app_data.join("Tolaria");
let roots = runtime_resource_roots_for_env(None, None, Some(local_app_data.clone()));
let roots =
runtime_resource_roots_for_env_and_exe(None, None, Some(local_app_data.clone()), None);
assert_eq!(roots.iter().filter(|root| *root == &install_dir).count(), 1);
assert!(roots.contains(&local_app_data.join("tolaria")));
@@ -67,4 +105,20 @@ mod tests {
super::super::mcp_server_dir_candidates(Path::new("/repo/mcp-server"), &roots);
assert!(candidates.contains(&install_dir.join("mcp-server")));
}
#[test]
fn includes_macos_app_bundle_resources_from_executable_path() {
let executable = PathBuf::from("/Applications/Tolaria.app/Contents/MacOS/Tolaria");
let roots = runtime_resource_roots_for_env_and_exe(None, None, None, Some(&executable));
assert!(roots.contains(&PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources"
)));
let candidates =
super::super::mcp_server_dir_candidates(Path::new("/repo/mcp-server"), &roots);
assert!(candidates.contains(&PathBuf::from(
"/Applications/Tolaria.app/Contents/Resources/mcp-server"
)));
}
}

View File

@@ -1,6 +1,7 @@
use crate::ai_agents::AiAgentPermissionMode;
use crate::pi_cli::AgentStreamRequest;
use serde_json::{Map, Value};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::process::Stdio;
@@ -17,8 +18,12 @@ pub(crate) fn build_command(
request.permission_mode,
)?;
let mut command = crate::hidden_command(binary);
let target = crate::cli_agent_runtime::command_target_avoiding_windows_cmd_shim(binary)?;
let mut command = crate::hidden_command(&target.program);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
if let Some(first_arg) = target.first_arg {
command.arg(first_arg);
}
command
.args(build_args())
.arg(build_prompt(request))
@@ -56,9 +61,18 @@ fn seed_agent_dir(source_dir: &Path, agent_dir: &Path) -> Result<(), String> {
return Ok(());
}
for entry in std::fs::read_dir(source_dir)
.map_err(|error| format!("Failed to read Pi agent directory: {error}"))?
{
let entries = match std::fs::read_dir(source_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(format!(
"Failed to read Pi agent directory at {}: {error}",
source_dir.display()
));
}
};
for entry in entries {
let entry = entry.map_err(|error| format!("Failed to read Pi agent file: {error}"))?;
let target = agent_dir.join(entry.file_name());
copy_agent_entry(&entry.path(), &target)?;
@@ -75,8 +89,16 @@ fn same_directory(left: &Path, right: &Path) -> bool {
}
fn copy_agent_entry(source: &Path, target: &Path) -> Result<(), String> {
let metadata = std::fs::metadata(source)
.map_err(|error| format!("Failed to inspect Pi agent config: {error}"))?;
let metadata = match std::fs::metadata(source) {
Ok(metadata) => metadata,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(format!(
"Failed to inspect Pi agent config at {}: {error}",
source.display()
));
}
};
if metadata.is_dir() {
seed_agent_dir(source, target)
@@ -93,13 +115,30 @@ fn copy_agent_file(
metadata: std::fs::Metadata,
) -> Result<(), String> {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create Pi agent config parent: {error}"))?;
std::fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create Pi agent config parent at {}: {error}",
parent.display()
)
})?;
}
std::fs::copy(source, target)
.map_err(|error| format!("Failed to copy Pi agent config: {error}"))?;
std::fs::set_permissions(target, metadata.permissions())
.map_err(|error| format!("Failed to preserve Pi agent config permissions: {error}"))
match std::fs::copy(source, target) {
Ok(_) => {}
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(format!(
"Failed to copy Pi agent config from {} to {}: {error}",
source.display(),
target.display()
));
}
}
std::fs::set_permissions(target, metadata.permissions()).map_err(|error| {
format!(
"Failed to preserve Pi agent config permissions at {}: {error}",
target.display()
)
})
}
fn build_args() -> Vec<String> {
@@ -265,10 +304,15 @@ mod tests {
}
fn assert_pi_json_mode_args(args: &[String]) {
assert_eq!(args[0], "--mode");
assert_eq!(args[1], "json");
assert!(args.contains(&"--no-session".to_string()));
assert!(args.contains(&"--extension".to_string()));
assert_eq!(
(
args.first().map(String::as_str),
args.get(1).map(String::as_str),
args.iter().any(|arg| arg == "--no-session"),
args.iter().any(|arg| arg == "--extension"),
),
(Some("--mode"), Some("json"), true, true)
);
}
#[test]
@@ -289,10 +333,20 @@ mod tests {
}
fn assert_command_identity(command: &std::process::Command, actual_args: &[&OsStr]) {
assert_eq!(command.get_program(), OsStr::new("pi"));
assert_eq!(actual_args[0], OsStr::new("--mode"));
assert_eq!(actual_args[1], OsStr::new("json"));
assert_eq!(actual_args.last(), Some(&OsStr::new("Rename the note")));
assert_eq!(
(
command.get_program(),
actual_args.first().copied(),
actual_args.get(1).copied(),
actual_args.last().copied(),
),
(
OsStr::new("pi"),
Some(OsStr::new("--mode")),
Some(OsStr::new("json")),
Some(OsStr::new("Rename the note")),
)
);
}
fn assert_command_vault_scope(
@@ -305,6 +359,50 @@ mod tests {
assert!(agent_dir.join("mcp.json").exists());
}
#[test]
fn command_avoids_windows_cmd_shim_for_prompt_args() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
let source_agent_dir = tempfile::tempdir().unwrap();
let agent_dir = tempfile::tempdir().unwrap();
let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path());
let shim = agent_dir.path().join("pi.cmd");
let launcher = agent_dir
.path()
.join("node_modules")
.join("@withpi")
.join("pi")
.join("bin")
.join("pi.exe");
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::fs::write(&launcher, "native pi launcher").unwrap();
std::fs::write(
&shim,
r#"@ECHO off
"%~dp0\node_modules\@withpi\pi\bin\pi.exe" %*
"#,
)
.unwrap();
let command = build_command(&shim, &request(), agent_dir.path()).unwrap();
let actual_args = command.get_args().collect::<Vec<_>>();
assert_eq!(
(
command.get_program() != shim.as_os_str(),
command.get_program(),
actual_args.first().copied(),
actual_args.last().copied(),
),
(
true,
launcher.as_os_str(),
Some(OsStr::new("--mode")),
Some(OsStr::new("Rename the note")),
),
"Pi npm .cmd shims cannot be spawned directly on Windows"
);
}
#[test]
fn command_seeds_temp_agent_dir_from_existing_pi_config() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
@@ -367,6 +465,18 @@ mod tests {
);
}
#[test]
fn stale_pi_agent_config_entry_is_ignored() {
let source_agent_dir = tempfile::tempdir().unwrap();
let agent_dir = tempfile::tempdir().unwrap();
let stale_source = source_agent_dir.path().join("stale.json");
let target = agent_dir.path().join("stale.json");
copy_agent_entry(&stale_source, &target).unwrap();
assert!(!target.exists());
}
#[test]
fn mcp_config_includes_tolaria_server_for_active_vault() {
if let Ok(config) = build_mcp_config(
@@ -382,10 +492,20 @@ mod tests {
}
fn assert_base_mcp_config(json: &serde_json::Value) {
assert_eq!(json["settings"]["toolPrefix"], "none");
assert_eq!(json["mcpServers"]["tolaria"]["command"], "node");
assert_eq!(json["mcpServers"]["tolaria"]["lifecycle"], "lazy");
assert_eq!(json["mcpServers"]["tolaria"]["directTools"], true);
assert_eq!(
(
&json["settings"]["toolPrefix"],
&json["mcpServers"]["tolaria"]["command"],
&json["mcpServers"]["tolaria"]["lifecycle"],
&json["mcpServers"]["tolaria"]["directTools"],
),
(
&serde_json::json!("none"),
&serde_json::json!("node"),
&serde_json::json!("lazy"),
&serde_json::json!(true),
)
);
}
fn assert_tolaria_mcp_env(json: &serde_json::Value) {

View File

@@ -88,12 +88,19 @@ fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf>
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
first_existing_path_for_platform(stdout, cfg!(windows))
}
fn first_existing_path_for_platform(stdout: &str, windows: bool) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
if windows && !crate::cli_agent_runtime::has_windows_cli_extension(&candidate) {
return None;
}
candidate.exists().then_some(candidate)
})
}
@@ -358,6 +365,18 @@ mod tests {
assert_eq!(first_existing_path(&stdout), Some(pi));
}
#[test]
fn first_existing_windows_path_skips_extensionless_npm_wrapper() {
let dir = tempfile::tempdir().unwrap();
let wrapper = dir.path().join("pi");
let shim = dir.path().join("pi.cmd");
std::fs::write(&wrapper, "#!/bin/sh\n").unwrap();
std::fs::write(&shim, "@ECHO off\n").unwrap();
let stdout = format!("{}\n{}\n", wrapper.display(), shim.display());
assert_eq!(first_existing_path_for_platform(&stdout, true), Some(shim));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_pi_from_interactive_login_shell() {

View File

@@ -26,6 +26,7 @@ pub struct SearchOptions<'a> {
pub mode: &'a str,
pub limit: usize,
pub hide_gitignored_files: bool,
pub exclude_frontmatter: bool,
}
struct Utf8Boundary<'a> {
@@ -126,9 +127,29 @@ pub fn search_vault(
mode: _mode,
limit,
hide_gitignored_files: crate::settings::hide_gitignored_files_enabled(),
exclude_frontmatter: false,
})
}
fn strip_frontmatter(content: &str) -> &str {
let Some(rest) = content.strip_prefix("---") else {
return content;
};
match rest.find("\n---") {
Some(end) => rest[end + 4..].trim_start(),
None => content,
}
}
fn searchable_content(content: &str, exclude_frontmatter: bool) -> &str {
if exclude_frontmatter {
strip_frontmatter(content)
} else {
content
}
}
fn is_markdown_search_candidate(vault_dir: &Path, path: &Path) -> bool {
if !path.extension().is_some_and(|ext| ext == "md") {
return false;
@@ -164,7 +185,8 @@ pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result<SearchRes
Err(_) => continue,
};
let content_lower = content.to_lowercase();
let searchable_content = searchable_content(&content, options.exclude_frontmatter);
let content_lower = searchable_content.to_lowercase();
let filename = path
.file_name()
.and_then(|value| value.to_str())
@@ -183,7 +205,7 @@ pub fn search_vault_with_options(options: SearchOptions<'_>) -> Result<SearchRes
}
.score();
let snippet = SnippetRequest {
content: &content,
content: searchable_content,
query_lower: &query_lower,
}
.extract();
@@ -364,6 +386,7 @@ mod tests {
mode: "keyword",
limit: 10,
hide_gitignored_files: true,
exclude_frontmatter: false,
})
.unwrap();
let shown = search_vault_with_options(SearchOptions {
@@ -372,6 +395,7 @@ mod tests {
mode: "keyword",
limit: 10,
hide_gitignored_files: false,
exclude_frontmatter: false,
})
.unwrap();
@@ -379,4 +403,44 @@ mod tests {
assert_eq!(hidden.results[0].title, "Visible");
assert_eq!(shown.results.len(), 2);
}
#[test]
fn test_search_vault_can_exclude_frontmatter_from_content_matches() {
let dir = Builder::new()
.prefix("search-frontmatter-scope-")
.tempdir_in(std::env::current_dir().unwrap())
.unwrap();
fs::write(
dir.path().join("frontmatter-only.md"),
[
"---",
"Owner: hidden-frontmatter-keyword",
"---",
"",
"# Public Body",
"",
"The note body deliberately omits the hidden property token.",
]
.join("\n"),
)
.unwrap();
fs::write(
dir.path().join("body-match.md"),
"# Body Match\n\nBody includes hidden-frontmatter-keyword here.",
)
.unwrap();
let response = search_vault_with_options(SearchOptions {
vault_path: dir.path().to_str().unwrap(),
query: "hidden-frontmatter-keyword",
mode: "keyword",
limit: 10,
hide_gitignored_files: false,
exclude_frontmatter: true,
})
.unwrap();
assert_eq!(response.results.len(), 1);
assert_eq!(response.results[0].title, "Body Match");
}
}

View File

@@ -313,6 +313,55 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
save_settings_at(&preferred_app_config_path("settings.json")?, settings)
}
fn ai_workspace_sessions_path() -> Result<PathBuf, String> {
resolve_existing_or_preferred_app_config_path("ai-workspace-sessions.json")
}
fn get_ai_workspace_sessions_at(path: &PathBuf) -> Result<serde_json::Value, String> {
if !path.exists() {
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read AI workspace sessions: {}", e))?;
let sessions: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse AI workspace sessions: {}", e))?;
if sessions.is_object() {
Ok(sessions)
} else {
Ok(serde_json::json!({}))
}
}
fn save_ai_workspace_sessions_at(
path: &PathBuf,
sessions: serde_json::Value,
) -> Result<(), String> {
if !sessions.is_object() {
return Err("AI workspace sessions must be a JSON object".to_string());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
let json = serde_json::to_string_pretty(&sessions)
.map_err(|e| format!("Failed to serialize AI workspace sessions: {}", e))?;
fs::write(path, json).map_err(|e| format!("Failed to write AI workspace sessions: {}", e))
}
pub fn get_ai_workspace_sessions() -> Result<serde_json::Value, String> {
get_ai_workspace_sessions_at(&ai_workspace_sessions_path()?)
}
pub fn save_ai_workspace_sessions(sessions: serde_json::Value) -> Result<(), String> {
save_ai_workspace_sessions_at(
&preferred_app_config_path("ai-workspace-sessions.json")?,
sessions,
)
}
fn last_vault_file() -> Result<PathBuf, String> {
resolve_existing_or_preferred_app_config_path("last-vault.txt")
}
@@ -746,6 +795,47 @@ mod tests {
.contains("com.tolaria.app"));
}
#[test]
fn test_ai_workspace_sessions_roundtrip() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("ai-workspace-sessions.json");
let sessions = serde_json::json!({
"chat-1": {
"messages": [
{
"userMessage": "Hello",
"actions": [],
"response": "Hi"
}
],
"status": "done"
}
});
save_ai_workspace_sessions_at(&path, sessions.clone()).unwrap();
assert_eq!(get_ai_workspace_sessions_at(&path).unwrap(), sessions);
}
#[test]
fn test_ai_workspace_sessions_missing_file_returns_empty_object() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("ai-workspace-sessions.json");
assert_eq!(
get_ai_workspace_sessions_at(&path).unwrap(),
serde_json::json!({})
);
}
#[test]
fn test_ai_workspace_sessions_rejects_non_object_payload() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("ai-workspace-sessions.json");
assert!(save_ai_workspace_sessions_at(&path, serde_json::json!([])).is_err());
}
#[test]
fn test_get_last_vault_returns_none_for_missing_file() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -2,7 +2,7 @@ use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use super::getting_started::{agents_content_can_be_refreshed, AGENTS_MD};
use super::getting_started::{agents_content_is_known_managed_template, AGENTS_MD};
/// Content for `type.md` — describes the generic Type metamodel for the vault.
const TYPE_TYPE_DEFINITION: &str = "\
@@ -111,7 +111,9 @@ fn read_file_or_empty(path: &Path) -> String {
}
fn agents_content_can_be_replaced(content: &str) -> bool {
content.contains("See config/agents.md") || agents_content_can_be_refreshed(content)
content.trim().is_empty()
|| content.contains("See config/agents.md")
|| agents_content_is_known_managed_template(content)
}
fn root_agents_can_be_replaced(path: &Path) -> bool {
@@ -157,6 +159,30 @@ fn sync_managed_file(
Ok(true)
}
fn strip_markdown_frontmatter(content: &str) -> &str {
let Some(line_ending) = resolve_frontmatter_line_ending(content) else {
return content;
};
let after_open = &content[3 + line_ending.len()..];
let close_marker = format!("{line_ending}---");
let Some(close_index) = after_open.find(&close_marker) else {
return content;
};
let after_close = &after_open[close_index + close_marker.len()..];
after_close.strip_prefix(line_ending).unwrap_or(after_close)
}
fn resolve_frontmatter_line_ending(content: &str) -> Option<&'static str> {
if content.starts_with("---\r\n") {
return Some("\r\n");
}
content.starts_with("---\n").then_some("\n")
}
fn guidance_content_has_body(content: &str) -> bool {
!strip_markdown_frontmatter(content).trim().is_empty()
}
fn classify_guidance_file(
path: &Path,
matches_managed: fn(&str) -> bool,
@@ -171,6 +197,10 @@ fn classify_guidance_file(
return AiGuidanceFileState::Managed;
}
if !guidance_content_has_body(&content) {
return AiGuidanceFileState::Broken;
}
if can_replace(path) {
return AiGuidanceFileState::Broken;
}
@@ -468,17 +498,17 @@ mod tests {
);
}
fn assert_refreshes_outdated_managed_agents(run: VaultOperation) {
let outdated_agents = AGENTS_MD.replacen(
fn assert_preserves_edited_default_agents(run: VaultOperation) {
let edited_agents = AGENTS_MD.replacen(
"Store note type in the `type:` frontmatter field.",
"`type:` is the preferred type field. Tolaria still understands legacy aliases such as `Is A`.",
1,
);
let (_dir, vault) = run_with_agents(run, Some(&outdated_agents), None);
let (_dir, vault) = run_with_agents(run, Some(&edited_agents), None);
let content = read_root_agents(&vault);
assert!(content.contains("Store note type in the `type:` frontmatter field."));
assert!(!content.contains("Tolaria still understands legacy aliases such as `Is A`."));
assert!(content.contains("Tolaria still understands legacy aliases such as `Is A`."));
assert!(!content.contains("Store note type in the `type:` frontmatter field."));
}
fn assert_legacy_agents_move_to_root(
@@ -584,7 +614,7 @@ mod tests {
}
#[test]
fn test_seed_config_files_refreshes_stale_default_agents() {
fn test_seed_config_files_preserves_edited_stale_default_agents() {
let (_dir, vault) = create_vault();
write_root_agents(
&vault,
@@ -594,15 +624,13 @@ mod tests {
seed_config_files(vault.to_str().unwrap());
let content = read_root_agents(&vault);
assert!(content.contains("Use the first H1 as the note title."));
assert!(content.contains("Tolaria reads notes recursively from all folders"));
assert!(content.contains("views/*.yml"));
assert!(content.contains("Belongs to:"));
assert!(content.contains("Do not add `title:` frontmatter."));
assert!(!content.contains("Use the first H1 as the note title."));
}
#[test]
fn test_seed_config_files_refreshes_outdated_managed_agents() {
assert_refreshes_outdated_managed_agents(run_seed);
fn test_seed_config_files_preserves_edited_default_agents() {
assert_preserves_edited_default_agents(run_seed);
}
#[test]
@@ -724,8 +752,8 @@ mod tests {
}
#[test]
fn test_repair_config_files_refreshes_outdated_managed_agents() {
assert_refreshes_outdated_managed_agents(run_repair);
fn test_repair_config_files_preserves_edited_default_agents() {
assert_preserves_edited_default_agents(run_repair);
}
#[test]
@@ -746,6 +774,48 @@ mod tests {
);
}
#[test]
fn test_get_ai_guidance_status_treats_edited_agents_as_custom() {
let (_dir, vault) = create_vault();
let edited_agents = AGENTS_MD.replacen("type: Note", "type: Vault Guidance", 1)
+ "\n\n- Prefer the user's vault-specific naming conventions.\n";
write_root_agents(&vault, &edited_agents);
write_root_claude(&vault, CLAUDE_MD_SHIM);
write_root_gemini(&vault, GEMINI_MD_SHIM);
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
assert_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Custom,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: false,
}
);
}
#[test]
fn test_get_ai_guidance_status_reports_frontmatter_only_agents_as_broken() {
let (_dir, vault) = create_vault();
write_root_agents(&vault, "---\ntype: Vault Guidance\n---\n");
write_root_claude(&vault, CLAUDE_MD_SHIM);
write_root_gemini(&vault, GEMINI_MD_SHIM);
let status = get_ai_guidance_status(vault.to_str().unwrap()).unwrap();
assert_eq!(
status,
VaultAiGuidanceStatus {
agents_state: AiGuidanceFileState::Broken,
claude_state: AiGuidanceFileState::Managed,
gemini_state: AiGuidanceFileState::Managed,
can_restore: true,
}
);
}
#[test]
fn test_restore_ai_guidance_files_repairs_without_overwriting_custom_agents() {
let (_dir, vault) = create_vault();

View File

@@ -292,6 +292,10 @@ pub(super) fn agents_content_can_be_refreshed(content: &str) -> bool {
AgentsContent::new(content).can_be_refreshed()
}
pub(super) fn agents_content_is_known_managed_template(content: &str) -> bool {
AgentsContent::new(content).is_known_legacy_template()
}
/// Default AGENTS.md content — vault instructions for AI agents.
/// Describes Tolaria vault mechanics only; no user-specific structure.
/// The vault scanner will pick this up as a regular entry.

View File

@@ -12,12 +12,20 @@ fn normalize_relative_path(path: &str) -> String {
.to_string()
}
fn relative_path(vault_path: &Path, path: &Path) -> Option<String> {
fn stripped_relative_path(vault_path: &Path, path: &Path) -> Option<String> {
let relative = path.strip_prefix(vault_path).ok()?;
let normalized = normalize_relative_path(relative.to_string_lossy().as_ref());
(!normalized.is_empty()).then_some(normalized)
}
fn relative_path(vault_path: &Path, path: &Path) -> Option<String> {
stripped_relative_path(vault_path, path).or_else(|| {
let canonical_vault_path = vault_path.canonicalize().ok()?;
let canonical_path = path.canonicalize().ok()?;
stripped_relative_path(&canonical_vault_path, &canonical_path)
})
}
fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
entry.depth() == 0 || entry.file_name().to_string_lossy() != ".git"
}
@@ -334,4 +342,29 @@ mod tests {
let filtered = filter_gitignored_entries(dir.path(), entries, true);
assert_eq!(entry_paths(dir.path(), &filtered), vec!["notes/local.md"]);
}
#[cfg(unix)]
#[test]
fn filters_entries_with_real_paths_when_vault_root_is_symlinked() {
let dir = TempDir::new().unwrap();
let real_root = dir.path().join("real-vault");
let symlink_root = dir.path().join("linked-vault");
fs::create_dir_all(&real_root).unwrap();
std::os::unix::fs::symlink(&real_root, &symlink_root).unwrap();
init_git_repo(&symlink_root);
write_file(&real_root, ".gitignore", "tmp/\n");
write_file(&real_root, "visible.md", "# Visible\n");
write_file(&real_root, "tmp/hidden.md", "# Hidden\n");
let filtered = filter_gitignored_entries(
&symlink_root,
vec![
entry(&real_root, "visible.md"),
entry(&real_root, "tmp/hidden.md"),
],
true,
);
assert_eq!(entry_paths(&real_root, &filtered), vec!["visible.md"]);
}
}

View File

@@ -11,6 +11,7 @@
},
"app": {
"withGlobalTauri": true,
"macOSPrivateApi": true,
"windows": [
{
"title": "Tolaria",
@@ -72,6 +73,11 @@
]
},
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["tolaria"]
}
},
"updater": {
"endpoints": [
"https://refactoringhq.github.io/tolaria/stable/latest.json"

View File

@@ -0,0 +1,61 @@
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
use tolaria_lib::git::{git_add_remote, git_commit, git_remote_status};
fn run_git(path: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(path)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
fn setup_repo() -> TempDir {
let dir = TempDir::new().unwrap();
run_git(dir.path(), &["init", "-b", "main"]);
dir
}
fn setup_bare_repo() -> TempDir {
let dir = TempDir::new().unwrap();
run_git(dir.path(), &["init", "--bare"]);
dir
}
#[test]
fn git_add_remote_ignores_name_only_origin_config() {
let local = setup_repo();
fs::write(local.path().join("note.md"), "# Note\n").unwrap();
git_commit(local.path().to_str().unwrap(), "initial").unwrap();
run_git(local.path(), &["config", "remote.origin.prune", "true"]);
let remote_names = Command::new("git")
.args(["remote"])
.current_dir(local.path())
.output()
.unwrap();
assert!(String::from_utf8_lossy(&remote_names.stdout).contains("origin"));
let bare = setup_bare_repo();
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "connected");
assert!(
git_remote_status(local.path().to_str().unwrap())
.unwrap()
.has_remote
);
}

View File

@@ -369,6 +369,7 @@ vi.mock('@blocknote/core', () => ({
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
createCodeBlockSpec: vi.fn(() => ({})),
createExtension: (factory: unknown) => () => factory,
createStyleSpec: vi.fn(() => ({})),
createVideoBlockConfig: vi.fn(() => ({})), defaultInlineContentSpecs: {},
filterSuggestionItems: vi.fn(() => []), videoParse: vi.fn(() => undefined),
}))

View File

@@ -13,6 +13,7 @@ import { CommitDialog } from './components/CommitDialog'
import { PulseView } from './components/PulseView'
import { StatusBar } from './components/StatusBar'
import { AppAiWorkspaceSurface } from './components/AppAiWorkspaceSurface'
import { AiWorkspaceFloatingButton } from './components/AiWorkspaceFloatingButton'
import { AiWorkspaceWindowApp } from './components/AiWorkspaceWindowApp'
import { SettingsPanel } from './components/SettingsPanel'
import { CloneVaultModal } from './components/CloneVaultModal'
@@ -20,7 +21,6 @@ import { FeedbackDialog } from './components/FeedbackDialog'
import { McpSetupDialog } from './components/McpSetupDialog'
import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs'
import { StartupScreen } from './components/StartupScreen'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus'
@@ -30,10 +30,8 @@ import { useRecentVaultWrites, useVaultWatcher } from './hooks/useVaultWatcher'
import { useSettings } from './hooks/useSettings'
import { useNoteWidthMode } from './hooks/useNoteWidthMode'
import { useNoteActions } from './hooks/useNoteActions'
import { planNewTypeCreation } from './hooks/useNoteCreation'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useGitRepositories } from './hooks/useGitRepositories'
import { useViewMode, type ViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
import { useAppCommands } from './hooks/useAppCommands'
import { triggerCommitEntryAction } from './utils/commitEntryAction'
@@ -44,52 +42,45 @@ import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater, restartApp } from './hooks/useUpdater'
import { useAutoSync } from './hooks/useAutoSync'
import { useConflictResolver } from './hooks/useConflictResolver'
import { useZoom } from './hooks/useZoom'
import { useVaultConfig } from './hooks/useVaultConfig'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useGettingStartedClone } from './hooks/useGettingStartedClone'
import { useNetworkStatus } from './hooks/useNetworkStatus'
import { useAppNavigation } from './hooks/useAppNavigation'
import {
applyMainWindowSizeConstraints,
getMainWindowMinWidth,
useMainWindowSizeConstraints,
} from './hooks/useMainWindowSizeConstraints'
import { useAiActivity } from './hooks/useAiActivity'
import { useBulkActions } from './hooks/useBulkActions'
import { useDeleteActions } from './hooks/useDeleteActions'
import { useFolderActions } from './hooks/useFolderActions'
import { useFileActions } from './hooks/useFileActions'
import { useDeepLinks } from './hooks/useDeepLinks'
import { useNoteGitUrls } from './hooks/useNoteGitUrls'
import { useLayoutPanels } from './hooks/useLayoutPanels'
import { useConflictFlow } from './hooks/useConflictFlow'
import { useAppSave } from './hooks/useAppSave'
import { useNoteRetargetingUi } from './hooks/useNoteRetargetingUi'
import { useVaultBridge } from './hooks/useVaultBridge'
import { useSavedViewOrdering } from './hooks/useSavedViewOrdering'
import { useAppViewActions } from './hooks/useAppViewActions'
import { useAppWindowControls } from './hooks/useAppWindowControls'
import { useAiWorkspacePublishedContext } from './hooks/useAiWorkspacePublishedContext'
import {
useNeighborhoodEntry,
useNeighborhoodEscape,
useNeighborhoodHistoryBack,
useSelectionSanitizer,
} from './hooks/useNeighborhoodSelection'
import { createViewFilename } from './utils/viewFilename'
import { nextViewOrder } from './utils/viewOrdering'
import { viewMatchesSelection, viewVaultPath } from './utils/viewIdentity'
import { viewCreationVaultPath } from './utils/viewTargetVault'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
import { DeleteProgressNotice } from './components/DeleteProgressNotice'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { AiWorkspaceConversationSetting, GitSetupPreference, SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from './types'
import type { NoteListItem } from './utils/ai-context'
import type { AiWorkspaceConversationSetting, GitSetupPreference, SidebarSelection, InboxPeriod, VaultEntry, WorkspaceIdentity } from './types'
import { initializeNoteProperties } from './utils/initializeNoteProperties'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
import { isWindows } from './utils/platform'
import { getPulledVaultUpdateOptions, refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
import { viewMatchesSelection } from './utils/viewIdentity'
import { isAiWorkspaceWindow, isNoteWindow, getNoteWindowParams, type NoteWindowParams } from './utils/windowMode'
import { GitSetupDialog } from './components/GitRequiredModal'
import { RenameDetectedBanner } from './components/RenameDetectedBanner'
@@ -109,7 +100,6 @@ import { normalizeReleaseChannel } from './lib/releaseChannel'
import {
buildVaultAiGuidanceRefreshKey,
} from './lib/vaultAiGuidance'
import { isActiveVaultUnavailableError } from './utils/vaultErrors'
import { hasNoteIconValue } from './utils/noteIcon'
import {
INBOX_SELECTION,
@@ -122,6 +112,7 @@ import {
vaultPathForEntry,
} from './utils/workspaces'
import { activeGitRepositories } from './utils/gitRepositories'
import { isMarkdownEntry } from './utils/typeDefinitions'
import { useVisibleWorkspaceEntries, useWorkspaceGraphState } from './hooks/useWorkspaceGraphState'
import { useGitSetupState } from './hooks/useGitSetupState'
import { AppPreferencesProvider, useAppPreferences } from './hooks/useAppPreferences'
@@ -133,31 +124,19 @@ import { useStartupScreenState } from './hooks/useStartupScreenState'
import { useGitFileWorkflows } from './hooks/useGitFileWorkflows'
import { useAutoGitWork } from './hooks/useAutoGitWork'
import { useAppAiWorkspaceBridge } from './hooks/useAppAiWorkspaceBridge'
import type { AiWorkspaceWindowContext } from './utils/openAiWorkspaceWindow'
import { useAiWorkspaceWindowBridgeEvents } from './hooks/useAiWorkspaceWindowBridgeEvents'
import { useMcpSetupDialogController } from './hooks/useMcpSetupDialogController'
import {
activeVaultModifiedFiles,
aiWorkspaceWindowContextForPath,
canCustomizeColumnsForSelection,
isActiveElementInsideEditorSurface,
mergeModifiedFiles,
runNativeTextHistoryCommand,
shouldPreferOnboardingVaultPath,
} from './utils/appOrchestration'
import './App.css'
const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror'
function isActiveElementInsideEditorSurface(): boolean {
const activeElement = document.activeElement
if (!(activeElement instanceof HTMLElement)) return false
return Boolean(activeElement.closest(ACTIVE_EDITOR_SURFACE_SELECTOR))
}
function isTextEditingElementFocused(): boolean {
const activeElement = document.activeElement
if (!(activeElement instanceof HTMLElement)) return false
return activeElement.tagName === 'INPUT'
|| activeElement.tagName === 'TEXTAREA'
|| activeElement.isContentEditable
|| activeElement.closest('[contenteditable="true"]') !== null
}
function runNativeTextHistoryCommand(command: 'undo' | 'redo'): boolean {
if (!isTextEditingElementFocused()) return false
return document.execCommand(command)
}
// Type declarations for mock content storage and test overrides
declare global {
interface Window {
@@ -169,85 +148,6 @@ declare global {
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
function modifiedFileKey(file: ModifiedFile): string {
return `${file.vaultPath ?? ''}\0${file.relativePath}\0${file.status}`
}
function activeVaultModifiedFiles(files: ModifiedFile[], vaultPath: string): ModifiedFile[] {
return files.map((file) => ({ ...file, vaultPath: file.vaultPath ?? vaultPath }))
}
function mergeModifiedFiles(...groups: ModifiedFile[][]): ModifiedFile[] {
const byKey = new Map<string, ModifiedFile>()
for (const group of groups) {
for (const file of group) {
byKey.set(modifiedFileKey(file), file)
}
}
return [...byKey.values()]
}
function shouldPreferOnboardingVaultPath(
onboardingState: { status: string; vaultPath?: string },
vaults: Array<{ path: string }>,
): onboardingState is { status: 'ready'; vaultPath: string } {
return onboardingState.status === 'ready'
&& typeof onboardingState.vaultPath === 'string'
&& onboardingState.vaultPath.length > 0
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
}
function canCustomizeColumnsForSelection(
selection: SidebarSelection,
explicitOrganizationEnabled: boolean,
): boolean {
if (selection.kind === 'view') return true
if (selection.kind !== 'filter') return false
if (selection.filter === 'all') return true
return explicitOrganizationEnabled && selection.filter === 'inbox'
}
function viewsForVault(views: ViewFile[], vaultPath: string): ViewFile[] {
return views.filter((view) => !view.rootPath || view.rootPath === vaultPath)
}
function viewSelection(filename: string, rootPath?: string): SidebarSelection {
return rootPath
? { kind: 'view', filename, rootPath }
: { kind: 'view', filename }
}
function savedViewFilename(
definition: ViewDefinition,
editingView: { filename: string } | null,
existingViews: ViewFile[],
): string {
return editingView
? editingView.filename
: createViewFilename(definition.name, existingViews.map((view) => view.filename))
}
function savedViewDefinition(
definition: ViewDefinition,
editingView: { definition: ViewDefinition } | null,
existingViews: ViewFile[],
): ViewDefinition {
return editingView
? { ...editingView.definition, ...definition }
: { ...definition, order: nextViewOrder(existingViews) }
}
function shouldPreserveViewRootPath(views: ViewFile[], editingRootPath?: string): boolean {
return Boolean(editingRootPath) || views.some((view) => view.rootPath)
}
function aiWorkspaceWindowContextForPath(resolvedPath: string): AiWorkspaceWindowContext {
return {
vaultPath: resolvedPath,
vaultPaths: resolvedPath ? [resolvedPath] : [],
}
}
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function App() {
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
@@ -262,6 +162,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
const aiWorkspaceWindow = false
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
const [pendingNoteListPdfExportPath, setPendingNoteListPdfExportPath] = useState<string | null>(null)
const selectionRef = useRef<SidebarSelection>(DEFAULT_SELECTION)
const neighborhoodHistoryRef = useRef<SidebarSelection[]>([])
const inboxPeriod: InboxPeriod = 'all'
@@ -283,11 +184,10 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
const visibleNotesRef = useRef<VaultEntry[]>([])
const multiSelectionCommandRef = useRef<NoteListMultiSelectionCommands | null>(null)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const [gitHistoryRefreshKey, setGitHistoryRefreshKey] = useState(0)
const dialogs = useDialogs()
const { closeAIChat, openAIChat, showAIChat } = dialogs
const [showFeedback, setShowFeedback] = useState(false)
const [showMcpSetupDialog, setShowMcpSetupDialog] = useState(false)
const [mcpDialogAction, setMcpDialogAction] = useState<'connect' | 'disconnect' | null>(null)
const openFeedback = useCallback(() => setShowFeedback(true), [])
const closeFeedback = useCallback(() => setShowFeedback(false), [])
const openDocs = useCallback(() => {
@@ -348,7 +248,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onVaultReady: handleOnboardingVaultReady,
registerVault: registerVaultSelection,
}, vaultSwitcher.loaded)
const aiAgentsStatus = useAiAgentsStatus()
const aiAgentsStatus = useAiAgentsStatus({
enabled: aiFeaturesEnabled && !noteWindowParams && !aiWorkspaceWindow,
})
const aiAgentsOnboarding = useAiAgentsOnboarding(
aiFeaturesEnabled && onboarding.state.status === 'ready' && !noteWindowParams && !aiWorkspaceWindow,
)
@@ -361,14 +263,12 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
? onboarding.state.vaultPath
: vaultSwitcher.vaultPath
)
const aiWorkspaceWindowContext = aiWorkspaceWindowContextForPath(resolvedPath)
const aiWorkspaceWindowContext = useMemo(() => aiWorkspaceWindowContextForPath(resolvedPath), [resolvedPath])
const [settingsInitialSectionId, setSettingsInitialSectionId] = useState<string | null>(null)
const {
effectiveShowAIChat,
handleDockCurrentAiWorkspaceWindow,
handleOpenAiSettings,
handleOpenDockedAiWorkspace,
handlePopOutAiWorkspace,
} = useAppAiWorkspaceBridge({
aiFeaturesEnabled,
aiWorkspaceWindow,
@@ -377,8 +277,18 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
openSettings: dialogs.openSettings,
setSettingsInitialSectionId,
showAIChat,
windowContext: aiWorkspaceWindowContext,
})
const handleToggleAiWorkspace = useCallback(() => {
if (effectiveShowAIChat) {
closeAIChat()
return
}
handleOpenDockedAiWorkspace()
}, [closeAIChat, effectiveShowAIChat, handleOpenDockedAiWorkspace])
const [lastAiWorkspaceConversationId, setLastAiWorkspaceConversationId] = useState<string | null>(null)
const handleActiveAiWorkspaceConversationChange = useCallback((id: string) => {
setLastAiWorkspaceConversationId(id)
}, [])
const {
folderVaults,
graphDefaultWorkspacePath,
@@ -505,16 +415,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
gitRepoState,
resolvedPath,
})
const {
mcpStatus,
connectMcp,
disconnectMcp,
mcpConfigSnippet,
mcpConfigLoading,
mcpConfigError,
loadMcpConfigSnippet,
copyMcpConfig,
} = useMcpStatus(resolvedPath, setToastMessage, appLocale)
const mcpSetupDialog = useMcpSetupDialogController(resolvedPath, setToastMessage, appLocale)
const loadDefaultVaultModifiedFiles = vault.loadModifiedFiles
const loadAllGitModifiedFiles = gitSurfaces.loadAllModifiedFiles
const loadModifiedFilesForRepository = gitSurfaces.loadModifiedFilesForRepository
@@ -541,39 +442,6 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
void refreshAllGitRemoteStatuses()
}, [gitFeaturesEnabled, gitRepoState, loadVaultModifiedFiles, refreshAllGitRemoteStatuses, refreshGitRemoteStatus])
const openMcpSetupDialog = useCallback(() => {
setShowMcpSetupDialog(true)
}, [])
const closeMcpSetupDialog = useCallback(() => {
if (mcpDialogAction !== null) return
setShowMcpSetupDialog(false)
}, [mcpDialogAction])
const handleConnectMcp = useCallback(async () => {
setMcpDialogAction('connect')
try {
const didConnect = await connectMcp()
if (didConnect) setShowMcpSetupDialog(false)
} finally {
setMcpDialogAction(null)
}
}, [connectMcp])
const handleDisconnectMcp = useCallback(async () => {
setMcpDialogAction('disconnect')
try {
const didDisconnect = await disconnectMcp()
if (didDisconnect) setShowMcpSetupDialog(false)
} finally {
setMcpDialogAction(null)
}
}, [disconnectMcp])
const handleCopyMcpConfig = useCallback(() => {
void copyMcpConfig()
}, [copyMcpConfig])
const handleOpenSettings = useCallback(() => {
setSettingsInitialSectionId(null)
dialogs.openSettings()
@@ -584,10 +452,6 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
dialogs.openSettings()
}, [dialogs])
const handleLoadMcpConfigSnippet = useCallback(() => {
void loadMcpConfigSnippet().catch(() => undefined)
}, [loadMcpConfigSnippet])
const {
detectedRenames,
handleUpdateWikilinks,
@@ -661,6 +525,11 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
closeAllTabs,
openTabWithContent,
} = notes
const noteActiveTabPath = notes.activeTabPath
const noteActiveTabPathRef = notes.activeTabPathRef
const refocusActiveEditor = useCallback((path: string) => {
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path } }))
}, [])
useNoteWindowLifecycle({
activeTabPath: notes.activeTabPath,
handleSelectNote,
@@ -671,21 +540,20 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
})
const handleVaultUpdate = useCallback(async (
updatedFiles: string[],
options: { preserveFocusedEditor?: boolean; vaultPath?: string } = {},
options: { vaultPath?: string } = {},
) => {
const updateVaultPath = options.vaultPath ?? resolvedPath
await refreshPulledVaultState({
activeTabPath: notes.activeTabPath,
activeTabPath: noteActiveTabPath,
closeAllTabs,
getActiveTabPath: () => notes.activeTabPathRef.current,
getActiveTabPath: () => noteActiveTabPathRef.current,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
shouldKeepActiveEditorMounted: options.preserveFocusedEditor
? isActiveElementInsideEditorSurface
: undefined,
reloadFolders: vault.reloadFolders,
reloadVault: vault.reloadVault,
reloadViews: vault.reloadViews,
replaceActiveTab: handleReplaceActiveTab,
refocusActiveEditor,
shouldRefocusActiveEditor: isActiveElementInsideEditorSurface,
updatedFiles,
vaultPath: updateVaultPath,
})
@@ -693,8 +561,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
}, [
closeAllTabs,
handleReplaceActiveTab,
notes.activeTabPath,
notes.activeTabPathRef,
noteActiveTabPath,
noteActiveTabPathRef,
refocusActiveEditor,
refreshGitModifiedFiles,
resolvedPath,
vault.reloadFolders,
@@ -703,14 +572,14 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
vault.unsavedPaths,
])
const handlePulledVaultUpdate = useCallback(
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, {
...getPulledVaultUpdateOptions(),
vaultPath,
}),
(updatedFiles: string[], vaultPath: string) => handleVaultUpdate(updatedFiles, { vaultPath }),
[handleVaultUpdate],
)
const refreshGitHistorySurfaces = useCallback(() => {
setGitHistoryRefreshKey((key) => key + 1)
}, [])
const handleFocusedVaultUpdate = useCallback(
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles, { preserveFocusedEditor: true }),
(updatedFiles: string[]) => handleVaultUpdate(updatedFiles),
[handleVaultUpdate],
)
useEffect(() => {
@@ -737,6 +606,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
vaultPaths: activeGitRepositoryPaths,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: handlePulledVaultUpdate,
onSyncUpdated: refreshGitHistorySurfaces,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
@@ -779,12 +649,25 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
reloadViews: vault.reloadViews,
closeAllTabs,
replaceActiveTab: handleReplaceActiveTab,
refocusActiveEditor,
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
shouldKeepActiveEditorMounted: isActiveElementInsideEditorSurface,
shouldRefocusActiveEditor: isActiveElementInsideEditorSurface,
onSelectNote: notes.handleSelectNote,
activeTabPath: notes.activeTabPath,
getActiveTabPath: () => notes.activeTabPathRef.current,
})
const handleAiWorkspaceWindowOpenNote = notes.handleNavigateWikilink
const {
handleAgentFileCreated: handleAiWorkspaceWindowFileCreated,
handleAgentFileModified: handleAiWorkspaceWindowFileModified,
handleAgentVaultChanged: handleAiWorkspaceWindowVaultChanged,
} = vaultBridge
useAiWorkspaceWindowBridgeEvents({
onFileCreated: handleAiWorkspaceWindowFileCreated,
onFileModified: handleAiWorkspaceWindowFileModified,
onOpenNote: handleAiWorkspaceWindowOpenNote,
onVaultChanged: handleAiWorkspaceWindowVaultChanged,
})
const conflictFlow = useConflictFlow({
resolvedPath: autoSync.conflictVaultPath ?? graphDefaultWorkspacePath,
@@ -1200,206 +1083,49 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
}, [deleteActions, visibleEntries])
const shouldLoadGitHistory = !layout.inspectorCollapsed && !effectiveShowAIChat
const gitHistory = useGitHistory(notes.activeTabPath, loadGitHistoryForPath, shouldLoadGitHistory)
const gitHistory = useGitHistory(notes.activeTabPath, loadGitHistoryForPath, shouldLoadGitHistory, gitHistoryRefreshKey)
const handleCreateType = useCallback(async (name: string) => {
const created = await notes.handleCreateType(name)
if (created) setToastMessage(`Type "${name}" created`)
return created
}, [notes])
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
const trimmed = nextTypeName.trim()
if (!trimmed) return false
const plan = planNewTypeCreation({ entries: visibleEntries, typeName: trimmed, vaultPath: resolvedPath })
if (plan.status === 'blocked') {
setToastMessage(plan.message)
return false
}
let resolvedTypeName = plan.status === 'existing' ? plan.entry.title : trimmed
if (plan.status === 'create') {
try {
resolvedTypeName = (await notes.createTypeEntrySilent(trimmed)).title
} catch {
return false
}
}
await notes.handleUpdateFrontmatter(path, 'type', resolvedTypeName)
setToastMessage(
plan.status === 'create' && resolvedTypeName === missingType
? `Type "${resolvedTypeName}" created`
: `Type set to "${resolvedTypeName}"`,
)
return true
}, [notes, resolvedPath, visibleEntries])
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
const editing = dialogs.editingView
const targetVaultPath = viewCreationVaultPath({
editingRootPath: editing?.rootPath,
fallbackVaultPath: resolvedPath,
graphDefaultWorkspacePath,
multiWorkspaceEnabled,
})
const activeVaultViews = viewsForVault(vault.views, targetVaultPath)
const filename = savedViewFilename(definition, editing, activeVaultViews)
const nextDefinition = savedViewDefinition(definition, editing, activeVaultViews)
const target = isTauri() ? invoke : mockInvoke
try {
await target('save_view_cmd', { vaultPath: targetVaultPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection(viewSelection(
filename,
shouldPreserveViewRootPath(vault.views, editing?.rootPath) ? targetVaultPath : undefined,
))
return true
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setToastMessage(`Could not save view: ${message}`)
return false
}
}, [graphDefaultWorkspacePath, multiWorkspaceEnabled, resolvedPath, vault, handleSetSelection, dialogs.editingView])
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>, rootPath?: string) => {
const existing = vault.views.find((view) => viewMatchesSelection(view, viewSelection(filename, rootPath)))
if (!existing) return
const targetVaultPath = viewVaultPath(existing, resolvedPath)
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', {
vaultPath: targetVaultPath,
filename,
definition: { ...existing.definition, ...patch },
})
await vault.reloadViews()
}, [resolvedPath, vault])
const handleSidebarUpdateViewDefinition = useCallback((filename: string, patch: Partial<ViewDefinition>, rootPath?: string) => {
void handleUpdateViewDefinition(filename, patch, rootPath)
.then(() => {
trackEvent('view_updated', { source: 'sidebar_view_actions' })
if (typeof patch.name === 'string') setToastMessage(`View "${patch.name}" renamed`)
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err)
setToastMessage(`Could not save view: ${message}`)
})
}, [handleUpdateViewDefinition])
const handleEditView = useCallback((filename: string, rootPath?: string) => {
const view = vault.views.find((candidate) => viewMatchesSelection(candidate, viewSelection(filename, rootPath)))
if (view) dialogs.openEditView(filename, view.definition, view.rootPath)
}, [vault.views, dialogs])
const handleDeleteView = useCallback(async (filename: string, rootPath?: string) => {
const existing = vault.views.find((view) => viewMatchesSelection(view, viewSelection(filename, rootPath)))
if (!existing) return
const targetVaultPath = viewVaultPath(existing, resolvedPath)
const target = isTauri() ? invoke : mockInvoke
try {
await target('delete_view_cmd', { vaultPath: targetVaultPath, filename })
} catch (err) {
if (isActiveVaultUnavailableError(err)) {
vault.markVaultUnavailable(targetVaultPath)
return
}
throw err
}
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
if (selection.kind === 'view' && viewMatchesSelection(existing, selection)) {
handleSetSelection({ kind: 'filter', filter: 'all' })
}
setToastMessage('View deleted')
}, [resolvedPath, vault, selection, handleSetSelection])
const availableFields = useMemo(() => {
const builtIn = ['type', 'status', 'title', 'favorite', 'body']
if (!visibleEntries?.length) return builtIn
const customFields = new Set<string>()
for (const e of visibleEntries) {
if (e.properties) {
for (const key of Object.keys(e.properties)) customFields.add(key)
}
if (e.relationships) {
for (const key of Object.keys(e.relationships)) customFields.add(key)
}
}
return [...builtIn, ...Array.from(customFields).sort()]
}, [visibleEntries])
const {
availableFields,
handleCreateMissingType,
handleCreateOrUpdateView,
handleCreateType,
handleDeleteView,
handleEditView,
handleSidebarUpdateViewDefinition,
handleUpdateViewDefinition,
} = useAppViewActions({
editingView: dialogs.editingView,
graphDefaultWorkspacePath,
handleSetSelection,
multiWorkspaceEnabled,
notes,
onOpenEditView: dialogs.openEditView,
resolvedPath,
selection,
setToastMessage,
vault,
visibleEntries,
})
const bulkActions = useBulkActions(entryActions, visibleEntries, setToastMessage)
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
const rawToggleRef = useRef<() => void>(() => {})
const tableOfContentsToggleRef = useRef<() => void>(() => {})
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
const diffToggleRef = useRef<() => void>(() => {})
const findInNoteRef = useRef<((options?: { replace?: boolean }) => void) | null>(null)
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(
noteWindowParams || aiWorkspaceWindow ? 'editor-only' : undefined,
)
const zoom = useZoom()
const buildNumber = useBuildNumber()
const updateMainWindowConstraints = useCallback((
nextSidebarVisible: boolean,
nextNoteListVisible: boolean,
nextInspectorCollapsed: boolean = layout.inspectorCollapsed,
) => {
if (noteWindowParams || aiWorkspaceWindow) return
const minWidth = getMainWindowMinWidth({
sidebarVisible: nextSidebarVisible,
noteListVisible: nextNoteListVisible,
inspectorCollapsed: nextInspectorCollapsed,
sidebarWidth: layout.sidebarWidth,
noteListWidth: layout.noteListWidth,
inspectorWidth: layout.inspectorWidth,
})
void applyMainWindowSizeConstraints(minWidth, { growToFit: !isWindows() }).catch((err) => console.warn('[window] Size constraints failed:', err))
}, [aiWorkspaceWindow, layout.inspectorCollapsed, layout.inspectorWidth, layout.noteListWidth, layout.sidebarWidth, noteWindowParams])
const handleSetViewMode = useCallback((mode: ViewMode) => {
setViewMode(mode)
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)
updateMainWindowConstraints(sidebarVisible, noteListVisible, nextInspectorCollapsed)
}, [
const {
buildNumber,
diffToggleRef,
findInNoteRef,
handleCollapseSidebar,
handleSetViewMode,
handleToggleInspector,
noteListVisible,
pdfExportRef,
rawToggleRef,
sidebarVisible,
tableOfContentsToggleRef,
zoom,
} = useAppWindowControls({
layout,
noteListVisible,
sidebarVisible,
updateMainWindowConstraints,
])
useMainWindowSizeConstraints({
enabled: !noteWindowParams && !aiWorkspaceWindow,
sidebarVisible,
noteListVisible,
inspectorCollapsed: layout.inspectorCollapsed,
sidebarWidth: layout.sidebarWidth,
noteListWidth: layout.noteListWidth,
inspectorWidth: layout.inspectorWidth,
windowMode: Boolean(noteWindowParams) || aiWorkspaceWindow,
})
const { status: updateStatus, actions: updateActions } = useUpdater(settings.release_channel)
@@ -1515,20 +1241,23 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
const canReorderSavedViews = useMemo(() => (
vault.views.every((view) => !view.rootPath)
), [vault.views])
const toggleDiffCommand = useCallback(() => diffToggleRef.current(), [])
const toggleDiffCommand = useCallback(() => diffToggleRef.current(), [diffToggleRef])
const toggleRawEditorCommand = useMemo(
() => canToggleRichEditor ? () => rawToggleRef.current() : undefined,
[canToggleRichEditor],
[canToggleRichEditor, rawToggleRef],
)
const toggleTableOfContentsCommand = useCallback(() => {
if (notes.activeTabPath) tableOfContentsToggleRef.current()
}, [notes.activeTabPath])
}, [notes.activeTabPath, tableOfContentsToggleRef])
const exportNotePdfCommand = useCallback(() => {
pdfExportRef.current?.('app_command')
}, [pdfExportRef])
const findInNoteCommand = useCallback(() => {
findInNoteRef.current?.({ replace: false })
}, [])
}, [findInNoteRef])
const replaceInNoteCommand = useCallback(() => {
findInNoteRef.current?.({ replace: true })
}, [])
}, [findInNoteRef])
const pastePlainTextCommand = useCallback(() => {
void requestPlainTextPaste().catch((error) => {
console.warn('[paste] Failed to paste plain text:', error)
@@ -1593,7 +1322,70 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
updateFrontmatter: notes.handleUpdateFrontmatter,
setToastMessage,
})
const activeTabEntry = activeTab?.entry ?? null
const activeTabPath = activeTabEntry?.path
const handleSelectNoteForPdfExport = notes.handleSelectNote
const handleExportNotePdfFromList = useCallback((entry: VaultEntry) => {
if (!isMarkdownEntry(entry)) return
if (activeTabPath === entry.path) {
pdfExportRef.current?.('note_list_context_menu')
return
}
setPendingNoteListPdfExportPath(entry.path)
handleSelectNoteForPdfExport(entry)
}, [activeTabPath, handleSelectNoteForPdfExport, pdfExportRef])
useEffect(() => {
if (!pendingNoteListPdfExportPath) return
if (!activeTabEntry || activeTabPath !== pendingNoteListPdfExportPath) return
const frameId = requestAnimationFrame(() => {
if (isMarkdownEntry(activeTabEntry)) pdfExportRef.current?.('note_list_context_menu')
setPendingNoteListPdfExportPath(null)
})
return () => cancelAnimationFrame(frameId)
}, [activeTabEntry, activeTabPath, pendingNoteListPdfExportPath, pdfExportRef])
const {
isStartupLoading,
isVaultContentLoading,
shouldResumeFreshStartOnboarding,
shouldShowStartupScreen,
} = useStartupScreenState({
aiAgentsPromptVisible: aiAgentsOnboarding.showPrompt,
isNoteWindow: Boolean(noteWindowParams) || aiWorkspaceWindow,
onboardingState: onboarding.state,
runtimeMissingVaultPath,
selectedVaultPath,
settingsLoaded,
showMcpSetupDialog: mcpSetupDialog.open,
telemetryConsent: settings.telemetry_consent,
vaultIsLoading: vault.isLoading,
vaultSwitcher,
})
const deepLinks = useDeepLinks({
activeEntry: activeTab?.entry ?? null,
currentVaultPath: resolvedPath,
enabled: !noteWindowParams && !aiWorkspaceWindow,
entries: visibleEntries,
isVaultContentLoading,
locale: appLocale,
onSelectNote: notes.handleSelectNote,
onSwitchVault: vaultSwitcher.switchVault,
reloadVault: vault.reloadVault,
setToastMessage,
vaultListLoaded: vaultSwitcher.loaded,
vaults: vaultSwitcher.allVaults,
})
const activeEditorVaultPath = activeTab ? vaultPathForEntry(activeTab.entry, resolvedPath) : resolvedPath
const noteGitUrls = useNoteGitUrls({
currentVaultPath: resolvedPath,
locale: appLocale,
remoteStatusForRepository: gitSurfaces.remoteStatusForRepository,
setToastMessage,
})
const commandAiActions = useAppCommandAiActions(aiFeaturesEnabled, dialogs, aiAgentsStatus, vaultAiGuidanceStatus, restoreVaultAiGuidanceCommand, aiAgentPreferences)
const undoCommand = useCallback(() => {
if (runNativeTextHistoryCommand('undo')) return
@@ -1643,6 +1435,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onToggleDiff: toggleDiffCommand,
onToggleRawEditor: toggleRawEditorCommand,
onToggleTableOfContents: toggleTableOfContentsCommand,
onExportNoteAsPdf: activeDeletedFile ? undefined : exportNotePdfCommand,
noteWidth: activeNoteWidth,
defaultNoteWidth,
onSetNoteWidth: handleSetActiveNoteWidth,
@@ -1678,8 +1471,8 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
selectedUiLanguage,
onSetUiLanguage: handleSetUiLanguage,
onSetThemeMode: handleSetThemeMode,
mcpStatus,
onInstallMcp: openMcpSetupDialog,
mcpStatus: mcpSetupDialog.status,
onInstallMcp: mcpSetupDialog.openDialog,
onReloadVault: handleManualVaultReload,
onRepairVault: handleRepairVault,
onSetNoteIcon: handleSetNoteIconCommand,
@@ -1693,6 +1486,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onOpenInNewWindow: handleOpenInNewWindow,
onRevealActiveFile: fileActions.revealFile,
onCopyActiveFilePath: fileActions.copyFilePath,
onCopyActiveDeepLink: deepLinks.copyPathDeepLink,
onOpenActiveFileExternal: fileActions.openExternalFile,
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: toggleOrganizedCommand,
@@ -1703,48 +1497,59 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
canRestoreDeletedNote: !!activeDeletedFile,
})
const inboxCount = useMemo(() => filterInboxEntries(visibleEntries, inboxPeriod).length, [visibleEntries])
const aiNoteList = useMemo<NoteListItem[]>(() => {
const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox'
const filtered = isInbox
? filterInboxEntries(visibleEntries, inboxPeriod)
: filterEntries(visibleEntries, effectiveSelection, {
views: vault.views,
allNotesFileVisibility,
})
return filtered.map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note',
}))
}, [allNotesFileVisibility, visibleEntries, vault.views, effectiveSelection])
const aiNoteListFilter = useMemo(() => {
if (effectiveSelection.kind === 'sectionGroup') return { type: effectiveSelection.type, query: '' }
if (effectiveSelection.kind === 'entity') return { type: null, query: effectiveSelection.entry.title }
return { type: null, query: '' }
}, [effectiveSelection])
const {
isStartupLoading,
isVaultContentLoading,
shouldResumeFreshStartOnboarding,
shouldShowStartupScreen,
} = useStartupScreenState({
aiAgentsPromptVisible: aiAgentsOnboarding.showPrompt,
isNoteWindow: Boolean(noteWindowParams) || aiWorkspaceWindow,
onboardingState: onboarding.state,
runtimeMissingVaultPath,
selectedVaultPath,
settingsLoaded,
showMcpSetupDialog,
telemetryConsent: settings.telemetry_consent,
vaultIsLoading: vault.isLoading,
vaultSwitcher,
inboxCount,
noteList: aiNoteList,
noteListFilter: aiNoteListFilter,
} = useAiWorkspacePublishedContext({
activeTab,
allNotesFileVisibility,
context: aiWorkspaceWindowContext,
effectiveSelection,
entries: visibleEntries,
inboxPeriod,
tabs: notes.tabs,
views: vault.views,
})
const handleAiWorkspaceConversationsChange = useCallback((conversations: AiWorkspaceConversationSetting[]) => {
void saveSettings({ ...settings, ai_workspace_conversations: conversations })
}, [saveSettings, settings])
const aiWorkspaceSurface = <AppAiWorkspaceSurface mode={aiWorkspaceWindow ? 'window' : 'docked'} open={aiWorkspaceWindow || effectiveShowAIChat} aiAgentsStatus={aiAgentsStatus} aiModelProviders={settings.ai_model_providers ?? []} conversationSettings={settings.ai_workspace_conversations ?? null} conversationSettingsReady={settingsLoaded} defaultAiAgent={aiAgentPreferences.defaultAiAgent} defaultAiTarget={aiAgentPreferences.defaultAiTarget} defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness} defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady} activeEntry={activeTab?.entry ?? null} activeNoteContent={activeTab?.content ?? null} entries={visibleEntries} openTabs={notes.tabs.map((tab) => tab.entry)} noteList={aiNoteList} noteListFilter={aiNoteListFilter} onClose={aiWorkspaceWindow ? handleDockCurrentAiWorkspaceWindow : closeAIChat} onConversationSettingsChange={handleAiWorkspaceConversationsChange} onDock={aiWorkspaceWindow ? handleDockCurrentAiWorkspaceWindow : undefined} onPopOut={aiWorkspaceWindow ? undefined : handlePopOutAiWorkspace} onOpenAiSettings={aiWorkspaceWindow ? undefined : handleOpenAiSettings} onOpenNote={notes.handleNavigateWikilink} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} onUnsupportedAiPaste={setToastMessage} onFileCreated={vaultBridge.handleAgentFileCreated} onFileModified={vaultBridge.handleAgentFileModified} onVaultChanged={vaultBridge.handleAgentVaultChanged} vaultAiGuidanceStatus={vaultAiGuidanceStatus} vaultPath={activeEditorVaultPath} vaultPaths={writableVaultPaths} locale={appLocale} />
const aiWorkspaceSurface = (
<AppAiWorkspaceSurface
mode="side"
open={effectiveShowAIChat}
aiAgentsStatus={aiAgentsStatus}
aiModelProviders={settings.ai_model_providers ?? []}
conversationSettings={settings.ai_workspace_conversations ?? null}
conversationSettingsReady={settingsLoaded}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiTarget={aiAgentPreferences.defaultAiTarget}
defaultAiAgentReadiness={aiAgentPreferences.defaultAiAgentReadiness}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
initialActiveConversationId={lastAiWorkspaceConversationId ?? undefined}
activeEntry={activeTab?.entry ?? null}
activeNoteContent={activeTab?.content ?? null}
entries={visibleEntries}
openTabs={notes.tabs.map((tab) => tab.entry)}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onActiveConversationChange={handleActiveAiWorkspaceConversationChange}
onClose={closeAIChat}
onConversationSettingsChange={handleAiWorkspaceConversationsChange}
onOpenAiSettings={handleOpenAiSettings}
onOpenNote={notes.handleNavigateWikilink}
onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined}
onUnsupportedAiPaste={setToastMessage}
onFileCreated={vaultBridge.handleAgentFileCreated}
onFileModified={vaultBridge.handleAgentFileModified}
onVaultChanged={vaultBridge.handleAgentVaultChanged}
vaultAiGuidanceStatus={vaultAiGuidanceStatus}
vaultPath={activeEditorVaultPath}
vaultPaths={writableVaultPaths}
locale={appLocale}
/>
)
if (shouldShowStartupScreen) {
return (
<StartupScreen
@@ -1759,7 +1564,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
settings={settings}
settingsLoaded={settingsLoaded}
shouldResumeFreshStartOnboarding={shouldResumeFreshStartOnboarding}
showMcpSetupDialog={showMcpSetupDialog}
showMcpSetupDialog={mcpSetupDialog.open}
setToastMessage={setToastMessage}
toastMessage={toastMessage}
vaultSwitcher={vaultSwitcher}
@@ -1794,9 +1599,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
<>
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={gitSurfaces.historyRepositoryPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} />
<PulseView vaultPath={gitSurfaces.historyRepositoryPath} onOpenNote={handlePulseOpenNote} refreshKey={gitHistoryRefreshKey} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.historyRepositoryPath} onRepositoryChange={gitSurfaces.setHistoryRepositoryPath} locale={appLocale} />
) : (
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
<NoteList entries={visibleEntries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} loading={isVaultContentLoading} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={noteListModifiedFiles} modifiedFilesError={noteListModifiedFilesError} gitRepositories={gitRepositories} selectedGitRepositoryPath={gitSurfaces.changesRepositoryPath} onGitRepositoryChange={gitSurfaces.setChangesRepositoryPath} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={handleReplaceActiveTabWithQueuedDiff} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onExportPdf={handleExportNotePdfFromList} onToggleFavorite={entryActions.handleToggleFavorite} onToggleOrganized={explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined} onRevealFile={fileActions.revealFile} onCopyFilePath={fileActions.copyFilePath} canCopyGitUrl={noteGitUrls.canCopyEntryGitUrl} onCopyGitUrl={noteGitUrls.copyEntryGitUrl} onDiscardFile={handleDiscardFile} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} allNotesFileVisibility={allNotesFileVisibility} multiSelectionCommandRef={multiSelectionCommandRef} locale={appLocale} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1835,7 +1640,8 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onChangeWorkspace={activeDeletedFile ? undefined : handleChangeWorkspace}
onInitializeProperties={handleInitializeProperties}
showAIChat={effectiveShowAIChat}
onToggleAIChat={aiFeaturesEnabled ? dialogs.toggleAIChat : undefined}
onToggleAIChat={aiFeaturesEnabled ? handleToggleAiWorkspace : undefined}
aiWorkspaceSurface={aiWorkspaceSurface}
vaultPath={activeEditorVaultPath}
vaultPaths={writableVaultPaths}
noteList={aiNoteList}
@@ -1845,6 +1651,8 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onEnterNeighborhood={activeDeletedFile ? undefined : handleEnterNeighborhood}
onRevealFile={fileActions.revealFile}
onCopyFilePath={fileActions.copyFilePath}
onCopyDeepLink={activeDeletedFile ? undefined : deepLinks.copyEntryDeepLink}
onCopyGitUrl={activeDeletedFile || !activeTabEntry || !noteGitUrls.canCopyEntryGitUrl(activeTabEntry) ? undefined : noteGitUrls.copyEntryGitUrl}
onOpenExternalFile={fileActions.openExternalFile}
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
@@ -1856,6 +1664,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onToggleNoteWidth={handleToggleNoteWidth}
rawToggleRef={rawToggleRef}
tableOfContentsToggleRef={tableOfContentsToggleRef}
pdfExportRef={pdfExportRef}
findInNoteRef={findInNoteRef}
diffToggleRef={diffToggleRef}
canGoBack={canGoBack}
@@ -1872,14 +1681,25 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onKeepTheirs={conflictFlow.handleKeepTheirs}
flushPendingEditorContentRef={flushPendingEditorContentRef}
flushPendingRawContentRef={flushPendingRawContentRef}
onToast={setToastMessage}
locale={appLocale}
/>
</div>
{effectiveShowAIChat && aiWorkspaceSurface}
</div>
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onOpenAiWorkspace={aiFeaturesEnabled ? handleOpenDockedAiWorkspace : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} />
<StatusBar noteCount={visibleEntries.length} modifiedCount={gitModifiedCount} vaultPath={resolvedPath} defaultWorkspacePath={defaultWorkspacePath} vaults={vaultSwitcher.allVaults} multiWorkspaceEnabled={multiWorkspaceEnabled} onSwitchVault={vaultSwitcher.switchVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onOpenSettings={handleOpenSettings} onOpenVaultSettings={handleOpenVaultSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpSetupDialog.status} onInstallMcp={mcpSetupDialog.openDialog} locale={appLocale} />
{aiFeaturesEnabled && !effectiveShowAIChat ? (
<AiWorkspaceFloatingButton
statuses={aiAgentsStatus}
defaultAgent={aiAgentPreferences.defaultAiAgent}
defaultTarget={settings.default_ai_target ?? undefined}
providers={settings.ai_model_providers ?? []}
locale={appLocale}
updateBannerVisible={updateStatus.state !== 'idle' && updateStatus.state !== 'error'}
onOpen={handleToggleAiWorkspace}
/>
) : null}
<GitSetupDialog open={gitFeaturesEnabled && shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} onNeverForVault={neverForVaultGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
@@ -1929,9 +1749,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu
onCommit={conflictResolver.commitResolution}
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={gitRepoState !== 'missing'} onSave={saveSettings} onCopyMcpConfig={handleCopyMcpConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<McpSetupDialog open={showMcpSetupDialog} status={mcpStatus} busyAction={mcpDialogAction} manualConfigSnippet={mcpConfigSnippet} manualConfigLoading={mcpConfigLoading} manualConfigError={mcpConfigError} locale={appLocale} onClose={closeMcpSetupDialog} onConnect={handleConnectMcp} onCopyManualConfig={handleCopyMcpConfig} onDisconnect={handleDisconnectMcp} onLoadManualConfig={handleLoadMcpConfigSnippet} />
<SettingsPanel open={dialogs.showSettings} initialSectionId={settingsInitialSectionId} settings={settings} aiAgentsStatus={aiAgentsStatus} locale={appLocale} systemLocale={systemLocale} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} isGitVault={gitRepoState !== 'missing'} onSave={saveSettings} onCopyMcpConfig={mcpSetupDialog.copyManualConfig} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} locale={appLocale} />
<McpSetupDialog open={mcpSetupDialog.open} status={mcpSetupDialog.status} busyAction={mcpSetupDialog.busyAction} manualConfigSnippet={mcpSetupDialog.manualConfigSnippet} manualConfigLoading={mcpSetupDialog.manualConfigLoading} manualConfigError={mcpSetupDialog.manualConfigError} locale={appLocale} onClose={mcpSetupDialog.closeDialog} onConnect={mcpSetupDialog.connect} onCopyManualConfig={mcpSetupDialog.copyManualConfig} onDisconnect={mcpSetupDialog.disconnect} onLoadManualConfig={mcpSetupDialog.loadManualConfig} />
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
{deleteActions.confirmDelete && (
<ConfirmDeleteDialog

View File

@@ -0,0 +1,55 @@
import type { CSSProperties } from 'react'
import { cn } from '@/lib/utils'
import { getAiAgentDefinition, type AiAgentId } from '../lib/aiAgents'
interface AiAgentIconProps {
agent: AiAgentId
className?: string
size?: number
title?: string
}
const ICON_STYLE: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
overflow: 'hidden',
}
const AI_AGENT_ICON_SOURCES: Record<AiAgentId, string> = {
claude_code: '/ai-agent-icons/claude-code.svg',
codex: '/ai-agent-icons/codex.svg',
opencode: '/ai-agent-icons/opencode.svg',
pi: '/ai-agent-icons/pi.svg',
gemini: '/ai-agent-icons/gemini.svg',
kiro: '/ai-agent-icons/kiro.svg',
}
export function AiAgentIcon({
agent,
className,
size = 16,
title,
}: AiAgentIconProps) {
const label = title ?? getAiAgentDefinition(agent).label
return (
<span
className={cn('rounded-[5px]', className)}
style={{ ...ICON_STYLE, width: size, height: size }}
role={title ? 'img' : undefined}
aria-label={title ? label : undefined}
aria-hidden={title ? undefined : true}
>
<img
src={AI_AGENT_ICON_SOURCES[agent]}
alt=""
draggable={false}
width={size}
height={size}
style={{ display: 'block', width: size, height: size, objectFit: 'contain' }}
/>
</span>
)
}

View File

@@ -11,12 +11,14 @@ const missingStatuses: AiAgentStatuses = {
opencode: { status: 'missing', version: null },
pi: { status: 'missing', version: null },
gemini: { status: 'missing', version: null },
kiro: { status: 'missing', version: null },
}
const missingAgentInstallTestIds = [
'ai-agents-onboarding-install-codex',
'ai-agents-onboarding-install-opencode',
'ai-agents-onboarding-install-pi',
'ai-agents-onboarding-install-gemini',
'ai-agents-onboarding-install-kiro',
] as const
const installLinkTargets = [
['ai-agents-onboarding-install-claude_code', 'https://docs.anthropic.com/en/docs/claude-code'],
@@ -24,6 +26,7 @@ const installLinkTargets = [
['ai-agents-onboarding-install-opencode', 'https://opencode.ai/docs/'],
['ai-agents-onboarding-install-pi', 'https://pi.dev'],
['ai-agents-onboarding-install-gemini', 'https://google-gemini.github.io/gemini-cli/'],
['ai-agents-onboarding-install-kiro', 'https://kiro.dev/docs/cli'],
] as const
vi.mock('../utils/url', () => ({

View File

@@ -8,6 +8,7 @@ import {
type AiAgentsStatus,
} from '../lib/aiAgents'
import { openExternalUrl } from '../utils/url'
import { AiAgentIcon } from './AiAgentIcon'
import { OnboardingShell } from './OnboardingShell'
import { Button } from './ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
@@ -89,12 +90,15 @@ function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) {
key={definition.id}
className="flex items-center justify-between rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm"
>
<div className="space-y-1 text-left">
<div className="font-medium text-foreground">{definition.label}</div>
<div className="text-xs text-muted-foreground">
{ready
? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.`
: `${definition.label} is not installed yet.`}
<div className="flex min-w-0 items-start gap-3 text-left">
<AiAgentIcon agent={definition.id} size={18} className="mt-0.5" />
<div className="min-w-0 space-y-1">
<div className="font-medium text-foreground">{definition.label}</div>
<div className="text-xs text-muted-foreground">
{ready
? `${definition.label}${status.version ? ` ${status.version}` : ''} is ready.`
: `${definition.label} is not installed yet.`}
</div>
</div>
</div>
<span
@@ -169,6 +173,7 @@ export function AiAgentsOnboardingPrompt({
onClick={() => void openExternalUrl(getAiAgentDefinition(definition.id).installUrl)}
data-testid={`ai-agents-onboarding-install-${definition.id}`}
>
<AiAgentIcon agent={definition.id} size={16} />
Install {definition.label}
<ArrowUpRight className="size-4" />
</Button>

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiMessage } from './AiMessage'
@@ -6,10 +6,22 @@ vi.mock('./MarkdownContent', () => ({
MarkdownContent: ({ content }: { content: string }) => <div data-testid="markdown-content">{content}</div>,
}))
const writeClipboardText = vi.fn()
vi.mock('../utils/clipboardText', () => ({
writeClipboardText: (text: string) => writeClipboardText(text),
}))
describe('AiMessage', () => {
beforeEach(() => {
writeClipboardText.mockReset()
writeClipboardText.mockResolvedValue(undefined)
})
it('renders user message', () => {
render(<AiMessage userMessage="Hello AI" actions={[]} />)
const { container } = render(<AiMessage userMessage="Hello AI" actions={[]} />)
expect(screen.getByText('Hello AI')).toBeTruthy()
expect(container.querySelector('[style*="background: var(--state-hover)"]')).toBeTruthy()
})
it('renders response as markdown', () => {
@@ -18,9 +30,27 @@ describe('AiMessage', () => {
expect(screen.getByText('Here is the **answer**')).toBeTruthy()
})
it('shows undo button with response', () => {
it('shows assistant message actions with response', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Done" />)
expect(screen.getByTestId('undo-button')).toBeTruthy()
expect(screen.getByTestId('ai-message-actions')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Regenerate response' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Copy response' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Fork chat from here' })).toBeTruthy()
})
it('localizes reasoning and tool use chrome', () => {
render(
<AiMessage
userMessage="Fai qualcosa"
locale="it-IT"
reasoning="Sto pensando..."
reasoningDone
actions={[{ tool: 'search_notes', toolId: 't1', label: 'Cercato', status: 'done' }]}
/>,
)
expect(screen.getByRole('button', { name: /ragionamento/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /uso degli strumenti/i })).toBeTruthy()
})
it('shows reasoning expanded while streaming (reasoningDone=false)', () => {
@@ -52,7 +82,7 @@ describe('AiMessage', () => {
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('renders action cards', () => {
it('collapses tool use by default and shows the live call count', () => {
render(
<AiMessage
userMessage="Do something"
@@ -62,6 +92,14 @@ describe('AiMessage', () => {
]}
/>,
)
expect(screen.getByTestId('tool-use-toggle')).toHaveAttribute('aria-expanded', 'false')
expect(screen.getByTestId('tool-use-count').textContent).toBe('2')
expect(screen.getByTestId('tool-use-count')).toHaveAttribute('data-pending', 'true')
expect(screen.queryByTestId('ai-action-card')).toBeNull()
fireEvent.click(screen.getByTestId('tool-use-toggle'))
expect(screen.getByTestId('tool-use-toggle')).toHaveAttribute('aria-expanded', 'true')
expect(screen.getAllByTestId('ai-action-card')).toHaveLength(2)
})
@@ -74,6 +112,7 @@ describe('AiMessage', () => {
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('tool-use-toggle'))
fireEvent.click(screen.getByTestId('action-card-header'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md')
})
@@ -92,6 +131,29 @@ describe('AiMessage', () => {
expect(container.querySelector('.typing-dot')).toBeNull()
})
it('runs assistant message actions', () => {
const onRegenerate = vi.fn()
const onFork = vi.fn()
render(
<AiMessage
userMessage="Ask"
actions={[]}
messageId="message-1"
response="Done"
onFork={onFork}
onRegenerate={onRegenerate}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Regenerate response' }))
fireEvent.click(screen.getByRole('button', { name: 'Copy response' }))
fireEvent.click(screen.getByRole('button', { name: 'Fork chat from here' }))
expect(onRegenerate).toHaveBeenCalledWith('message-1')
expect(writeClipboardText).toHaveBeenCalledWith('Done')
expect(onFork).toHaveBeenCalledWith('message-1')
})
it('does not render reasoning block when no reasoning', () => {
render(<AiMessage userMessage="Ask" actions={[]} />)
expect(screen.queryByTestId('reasoning-toggle')).toBeNull()
@@ -153,6 +215,7 @@ describe('AiMessage', () => {
]}
/>,
)
fireEvent.click(screen.getByTestId('tool-use-toggle'))
const headers = screen.getAllByTestId('action-card-header')
// Both collapsed initially
expect(screen.queryByTestId('action-card-details')).toBeNull()

View File

@@ -1,8 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
import { CaretRight, CaretDown, Brain, ArrowsClockwise, Copy, GitBranch, Terminal } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { AiActionCard, type AiActionStatus } from './AiActionCard'
import { MarkdownContent } from './MarkdownContent'
import { translate, type AppLocale } from '../lib/i18n'
import type { NoteReference } from '../utils/ai-context'
import { writeClipboardText } from '../utils/clipboardText'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
export interface AiAction {
@@ -19,13 +22,17 @@ export interface AiMessageProps {
userMessage: string
references?: NoteReference[]
localMarker?: string
locale?: AppLocale
messageId?: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
onFork?: (messageId: string) => void
onOpenNote?: (path: string) => void
onNavigateWikilink?: (target: string) => void
onRegenerate?: (messageId: string) => void
}
function LocalMarker({ text }: { text: string }) {
@@ -44,8 +51,9 @@ function ReferencePill({ reference, onClick }: {
reference: NoteReference
onClick?: (path: string) => void
}) {
const color = getTypeColor(reference.type)
const lightColor = getTypeLightColor(reference.type)
const type = reference.type ?? null
const color = getTypeColor(type)
const lightColor = getTypeLightColor(type)
return (
<button type="button"
className="inline-flex items-center border-none cursor-pointer transition-opacity hover:opacity-80"
@@ -76,7 +84,7 @@ function UserBubble({ content, references, onOpenNote }: {
<div className="flex justify-end" style={{ marginBottom: 8 }}>
<div
style={{
background: 'var(--muted)',
background: 'var(--state-hover)',
color: 'var(--foreground)',
borderRadius: '12px 12px 2px 12px',
maxWidth: '85%',
@@ -98,8 +106,8 @@ function UserBubble({ content, references, onOpenNote }: {
)
}
function ReasoningBlock({ text, expanded, onToggle }: {
text: string; expanded: boolean; onToggle: () => void
function ReasoningBlock({ locale, text, expanded, onToggle }: {
locale: AppLocale; text: string; expanded: boolean; onToggle: () => void
}) {
const contentRef = useRef<HTMLDivElement>(null)
@@ -119,7 +127,7 @@ function ReasoningBlock({ text, expanded, onToggle }: {
data-testid="reasoning-toggle"
>
<Brain size={14} />
<span>Reasoning</span>
<span>{translate(locale, 'ai.message.reasoning')}</span>
{expanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</button>
{expanded && (
@@ -162,25 +170,168 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
)
}
function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateWikilink?: (target: string) => void }) {
function ToolUseBlock({
actions,
expanded,
expandedActionIds,
locale,
onOpenNote,
onToggle,
onToggleAction,
}: {
actions: AiAction[]
expanded: boolean
expandedActionIds: Set<string>
locale: AppLocale
onOpenNote?: (path: string) => void
onToggle: () => void
onToggleAction: (toolId: string) => void
}) {
const pending = actions.some((action) => action.status === 'pending')
return (
<div style={{ marginBottom: 4 }}>
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
<button type="button"
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
data-testid="undo-button"
<div style={{ marginBottom: 8 }}>
<button
type="button"
className="flex w-full cursor-pointer items-center gap-1.5 border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground"
style={{ fontSize: 12, padding: '4px 0' }}
aria-expanded={expanded}
onClick={onToggle}
data-testid="tool-use-toggle"
>
<ArrowCounterClockwise size={12} />
<span>Undo</span>
<Terminal size={14} />
<span>{translate(locale, 'ai.message.toolUse')}</span>
<span
className={`inline-flex h-4 min-w-4 items-center justify-center rounded-full ${pending ? 'animate-pulse' : ''}`}
style={{
background: 'var(--state-hover)',
color: 'var(--muted-foreground)',
fontSize: 10,
fontWeight: 600,
padding: '0 5px',
}}
data-pending={pending || undefined}
data-testid="tool-use-count"
>
{actions.length}
</span>
{expanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</button>
{expanded && (
<div data-testid="tool-use-content" style={{ marginTop: 4 }}>
<ActionCardsList
actions={actions}
onOpenNote={onOpenNote}
expandedIds={expandedActionIds}
onToggleExpand={onToggleAction}
/>
</div>
)}
</div>
)
}
function ResponseActions({
locale,
messageId,
onCopy,
onFork,
onRegenerate,
}: {
locale: AppLocale
messageId?: string
onCopy: () => void
onFork?: (messageId: string) => void
onRegenerate?: (messageId: string) => void
}) {
const regenerateDisabled = !messageId || !onRegenerate
const forkDisabled = !messageId || !onFork
return (
<div
className="mt-1.5 flex items-center gap-1 opacity-0 transition-opacity group-hover/ai-response:opacity-100 group-focus-within/ai-response:opacity-100"
data-testid="ai-message-actions"
>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 rounded-md p-0 text-muted-foreground hover:text-foreground"
disabled={regenerateDisabled}
aria-label={translate(locale, 'ai.message.regenerate')}
title={translate(locale, 'ai.message.regenerate')}
onClick={() => messageId && onRegenerate?.(messageId)}
data-testid="ai-message-regenerate"
>
<ArrowsClockwise size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 rounded-md p-0 text-muted-foreground hover:text-foreground"
aria-label={translate(locale, 'ai.message.copy')}
title={translate(locale, 'ai.message.copy')}
onClick={onCopy}
data-testid="ai-message-copy"
>
<Copy size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 rounded-md p-0 text-muted-foreground hover:text-foreground"
disabled={forkDisabled}
aria-label={translate(locale, 'ai.message.fork')}
title={translate(locale, 'ai.message.fork')}
onClick={() => messageId && onFork?.(messageId)}
data-testid="ai-message-fork"
>
<GitBranch size={14} />
</Button>
</div>
)
}
function ResponseBlock({
locale,
messageId,
onFork,
onNavigateWikilink,
onRegenerate,
text,
}: {
locale: AppLocale
messageId?: string
onFork?: (messageId: string) => void
onNavigateWikilink?: (target: string) => void
onRegenerate?: (messageId: string) => void
text: string
}) {
const handleCopy = useCallback(() => {
void writeClipboardText(text).catch((error) => {
console.warn('[ai] Failed to copy assistant message:', error)
})
}, [text])
return (
<div className="group/ai-response" style={{ marginBottom: 4 }}>
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
<ResponseActions
locale={locale}
messageId={messageId}
onCopy={handleCopy}
onFork={onFork}
onRegenerate={onRegenerate}
/>
</div>
)
}
function StreamingIndicator() {
return (
<div className="flex items-center gap-2 text-muted-foreground" style={{ fontSize: 12, padding: '4px 0' }}>
<div className="flex items-center gap-2 text-muted-foreground" style={{ fontSize: 12, marginTop: 8, padding: 0 }}>
<div className="flex gap-1">
<span className="typing-dot" />
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
@@ -198,10 +349,11 @@ export function AiMessage(props: AiMessageProps) {
return <ConversationMessage {...props} />
}
function ConversationMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
function ConversationMessage({ userMessage, references, locale = 'en', messageId, reasoning, reasoningDone, actions, response, isStreaming, onFork, onOpenNote, onNavigateWikilink, onRegenerate }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
const [toolUseExpanded, setToolUseExpanded] = useState(false)
// Auto: expanded while reasoning streams, collapsed once done
// User can manually toggle to override the auto state
@@ -222,20 +374,33 @@ function ConversationMessage({ userMessage, references, reasoning, reasoningDone
<UserBubble content={userMessage} references={references} onOpenNote={onOpenNote} />
{reasoning && (
<ReasoningBlock
locale={locale}
text={reasoning}
expanded={reasoningExpanded}
onToggle={() => setUserOverride(prev => !prev)}
/>
)}
{actions.length > 0 && (
<ActionCardsList
<ToolUseBlock
actions={actions}
expanded={toolUseExpanded}
expandedActionIds={expandedActions}
locale={locale}
onOpenNote={onOpenNote}
expandedIds={expandedActions}
onToggleExpand={toggleAction}
onToggle={() => setToolUseExpanded((current) => !current)}
onToggleAction={toggleAction}
/>
)}
{response && (
<ResponseBlock
locale={locale}
messageId={messageId}
text={response}
onFork={onFork}
onNavigateWikilink={onNavigateWikilink}
onRegenerate={onRegenerate}
/>
)}
{response && <ResponseBlock text={response} onNavigateWikilink={onNavigateWikilink} />}
{isStreaming && !response && <StreamingIndicator />}
</div>
)

View File

@@ -243,19 +243,19 @@ describe('AiPanel', () => {
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
expect(screen.getByText('Ask Claude Code about this note and its linked context')).toBeTruthy()
expect(screen.getByText('Ask anything to Claude Code')).toBeTruthy()
})
it('shows context bar with active entry title', () => {
it('does not render a context bar for the active entry', () => {
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" activeEntry={entry} entries={[entry]} />
)
expect(screen.getByTestId('context-bar')).toBeTruthy()
expect(screen.getByText('My Note')).toBeTruthy()
expect(screen.queryByTestId('context-bar')).toBeNull()
expect(screen.queryByText('My Note')).toBeNull()
})
it('shows linked count in context bar when entry has outgoing links', () => {
it('does not show linked count in a sub-header', () => {
const linked = makeEntry({ path: '/vault/linked.md', title: 'Linked Note' })
const entry = makeEntry({ title: 'My Note', outgoingLinks: ['Linked Note'] })
render(
@@ -264,11 +264,7 @@ describe('AiPanel', () => {
activeEntry={entry} entries={[entry, linked]}
/>
)
expect(screen.getByText('+ 1 linked')).toBeTruthy()
})
it('does not show context bar when no active entry', () => {
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
expect(screen.queryByText('+ 1 linked')).toBeNull()
expect(screen.queryByTestId('context-bar')).toBeNull()
})

View File

@@ -1,7 +1,6 @@
import { useCallback, useRef, type CSSProperties, type ReactNode, type RefObject } from 'react'
import {
AiPanelComposer,
AiPanelContextBar,
AiPanelHeader,
AiPanelMessageHistory,
} from './AiPanelChrome'
@@ -58,8 +57,12 @@ interface AiPanelViewProps {
entries?: VaultEntry[]
interactive?: boolean
showHeader?: boolean
showLeftBorder?: boolean
surface?: 'default' | 'sidebar'
composerControls?: ReactNode
onForkMessage?: (messageId: string) => void
onSendPrompt?: (text: string) => void
onMessageHistoryScrollStateChange?: (scrolled: boolean) => void
}
function readinessFromReadyFlag(ready: boolean | undefined): AiAgentReadiness {
@@ -95,14 +98,16 @@ function resolveAiPanelViewModel({
}
}
function aiPanelFrameStyle(isActive: boolean): CSSProperties {
function aiPanelFrameStyle(isActive: boolean, showLeftBorder: boolean): CSSProperties {
return {
outline: 'none',
borderLeft: isActive
? '2px solid var(--accent-blue)'
: '1px solid var(--border)',
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: 'border-color 0.3s ease',
borderLeft: showLeftBorder
? isActive
? '2px solid var(--accent-blue)'
: '1px solid var(--border)'
: undefined,
animation: showLeftBorder && isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: showLeftBorder ? 'border-color 0.3s ease' : undefined,
}
}
@@ -110,17 +115,21 @@ function AiPanelFrame({
children,
isActive,
panelRef,
showLeftBorder,
surface,
}: {
children: ReactNode
isActive: boolean
panelRef: RefObject<HTMLElement | null>
showLeftBorder: boolean
surface: 'default' | 'sidebar'
}) {
return (
<aside
ref={panelRef}
tabIndex={-1}
className="flex flex-1 flex-col overflow-hidden bg-background text-foreground"
style={aiPanelFrameStyle(isActive)}
className={`flex flex-1 flex-col overflow-hidden ${surface === 'sidebar' ? 'bg-sidebar text-sidebar-foreground' : 'bg-background text-foreground'}`}
style={aiPanelFrameStyle(isActive, showLeftBorder)}
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
@@ -139,12 +148,15 @@ export function AiPanelView({
defaultAiAgentReadiness: providedDefaultAiAgentReadiness,
defaultAiAgentReady: providedDefaultAiAgentReady,
locale = 'en',
activeEntry,
entries,
interactive = true,
showHeader = true,
showLeftBorder = true,
surface = 'default',
composerControls,
onForkMessage,
onSendPrompt,
onMessageHistoryScrollStateChange,
}: AiPanelViewProps) {
const view = resolveAiPanelViewModel({
defaultAiAgent: providedDefaultAiAgent,
@@ -158,7 +170,6 @@ export function AiPanelView({
agent,
input,
setInput,
linkedEntries,
hasContext,
isActive,
permissionMode,
@@ -184,7 +195,7 @@ export function AiPanelView({
}, [handleSend, isActive, onSendPrompt])
return (
<AiPanelFrame panelRef={panelRef} isActive={isActive}>
<AiPanelFrame panelRef={panelRef} isActive={isActive} showLeftBorder={showLeftBorder} surface={surface}>
{showHeader && (
<AiPanelHeader
agentLabel={view.agentLabel}
@@ -198,17 +209,17 @@ export function AiPanelView({
onNewChat={handleNewChat}
/>
)}
{activeEntry && (
<AiPanelContextBar activeEntry={activeEntry} linkedCount={linkedEntries.length} locale={locale} />
)}
<AiPanelMessageHistory
agentLabel={view.agentLabel}
agentReadiness={view.defaultAiAgentReadiness}
locale={locale}
messages={agent.messages}
isActive={isActive}
onForkMessage={onForkMessage}
onOpenNote={onOpenNote}
onNavigateWikilink={handleNavigateWikilink}
onRegenerateMessage={agent.regenerateMessage}
onScrollStateChange={onMessageHistoryScrollStateChange}
hasContext={hasContext}
/>
<AiPanelComposer

View File

@@ -1,5 +1,5 @@
import { memo, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { memo, useCallback, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
import { Sparkle, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { Button } from '@/components/ui/button'
import { ActionTooltip } from '@/components/ui/action-tooltip'
@@ -41,8 +41,11 @@ interface AiPanelMessageHistoryProps {
locale?: AppLocale
messages: AiAgentMessage[]
isActive: boolean
onForkMessage?: (messageId: string) => void
onOpenNote?: (path: string) => void
onNavigateWikilink?: (target: string) => void
onRegenerateMessage?: (messageId: string) => void
onScrollStateChange?: (scrolled: boolean) => void
hasContext: boolean
}
@@ -117,6 +120,7 @@ function ComposerInput({
onUnsupportedPaste={onUnsupportedAiPaste}
disabled={disabled}
placeholder={placeholder}
placeholderClassName={hasControls ? 'px-2 py-1.5 text-[13px] leading-5' : undefined}
inputRef={inputRef}
editorClassName={cn(
'max-h-[120px] overflow-y-auto overscroll-contain',
@@ -220,7 +224,7 @@ function AiPanelEmptyState({
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{t('ai.panel.empty.checkingTitle')}
</p>
@@ -237,7 +241,7 @@ function AiPanelEmptyState({
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{t('ai.panel.empty.missingTitle', { agent: agentLabel })}
</p>
@@ -253,7 +257,7 @@ function AiPanelEmptyState({
className="flex flex-col items-center justify-center text-center text-muted-foreground"
style={{ paddingTop: 40 }}
>
<Robot size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
{hasContext
? t('ai.panel.empty.withContextTitle', { agent: agentLabel })
@@ -292,7 +296,7 @@ export const AiPanelHeader = memo(function AiPanelHeader({
style={{ padding: '8px 12px', gap: 8 }}
>
<div className="flex items-center" style={{ gap: 8 }}>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<Sparkle size={16} className="shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
{t('ai.panel.title')}
@@ -422,20 +426,31 @@ export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
locale = 'en',
messages,
isActive,
onForkMessage,
onOpenNote,
onNavigateWikilink,
onRegenerateMessage,
onScrollStateChange,
hasContext,
}: AiPanelMessageHistoryProps) {
const containerRef = useRef<HTMLDivElement>(null)
const endRef = useRef<HTMLDivElement>(null)
const updateScrollState = useCallback(() => {
const element = containerRef.current
onScrollStateChange?.((element?.scrollTop ?? 0) > 1)
}, [onScrollStateChange])
useEffect(() => {
void isActive
void messages
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, isActive])
if (typeof window.requestAnimationFrame === 'function') window.requestAnimationFrame(updateScrollState)
else updateScrollState()
}, [messages, isActive, updateScrollState])
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
<div ref={containerRef} className="flex-1 overflow-y-auto" style={{ padding: 12 }} onScroll={updateScrollState}>
{messages.length === 0 && !isActive && (
<AiPanelEmptyState
agentLabel={agentLabel}
@@ -448,8 +463,12 @@ export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
<AiMessage
key={message.id ?? index}
{...message}
locale={locale}
messageId={message.id}
onFork={onForkMessage}
onOpenNote={onOpenNote}
onNavigateWikilink={onNavigateWikilink}
onRegenerate={onRegenerateMessage}
/>
))}
<div ref={endRef} />
@@ -487,7 +506,7 @@ export function AiPanelComposer({
return (
<div
className="flex shrink-0 flex-col border-t border-border"
className="flex shrink-0 flex-col"
style={{ padding: '6px 10px' }}
>
<div className={cn(

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { AiWorkspace } from './AiWorkspace'
import { buildAiWorkspaceTargetGroups } from './aiWorkspaceTargetGroups'
@@ -11,38 +11,62 @@ import {
import type { AiModelProvider } from '../lib/aiTargets'
import type { AgentStatus } from '../hooks/useCliAiAgent'
import { resetVaultConfigStore } from '../utils/vaultConfigStore'
import type { VaultEntry } from '../types'
import type { VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
let mockedAgentStatus: AgentStatus = 'idle'
let mockMessages: ReturnType<typeof import('../hooks/useCliAiAgent').useCliAiAgent>['messages'] = []
let controllerCalls: unknown[] = []
const { generateTitleMock } = vi.hoisted(() => ({
generateTitleMock: vi.fn(),
}))
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true, writable: true })
vi.mock('./useAiPanelController', () => ({
useAiPanelController: () => ({
agent: {
messages: [],
status: mockedAgentStatus,
sendMessage: vi.fn(),
clearConversation: vi.fn(),
addLocalMarker: vi.fn(),
},
input: '',
setInput: vi.fn(),
linkedEntries: [],
hasContext: false,
isActive: false,
permissionMode: 'safe',
handleSend: vi.fn(),
handleNavigateWikilink: vi.fn(),
handlePermissionModeChange: vi.fn(),
handleNewChat: vi.fn(),
}),
useAiPanelController: (args: unknown) => {
controllerCalls.push(args)
return {
agent: {
messages: mockMessages,
status: mockedAgentStatus,
sendMessage: vi.fn(),
clearConversation: vi.fn(),
addLocalMarker: vi.fn(),
},
input: '',
setInput: vi.fn(),
linkedEntries: [],
hasContext: false,
isActive: false,
permissionMode: 'safe',
handleSend: vi.fn(),
handleNavigateWikilink: vi.fn(),
handlePermissionModeChange: vi.fn(),
handleNewChat: vi.fn(),
}
},
}))
vi.mock('./AiPanel', () => ({
AiPanelView: ({
composerControls,
onMessageHistoryScrollStateChange,
onSendPrompt,
showHeader,
}: {
composerControls?: ReactNode
onMessageHistoryScrollStateChange?: (scrolled: boolean) => void
onSendPrompt?: (prompt: string) => void
showHeader?: boolean
}) => (
@@ -50,11 +74,18 @@ vi.mock('./AiPanel', () => ({
<button type="button" onClick={() => onSendPrompt?.('summarize quarterly sponsor outreach')}>
Send mocked prompt
</button>
<button type="button" onClick={() => onMessageHistoryScrollStateChange?.(true)}>
Mock history scroll
</button>
{composerControls}
</div>
),
}))
vi.mock('../utils/aiConversationTitle', () => ({
generateAiConversationTitleForTarget: generateTitleMock,
}))
function installedStatuses(): AiAgentsStatus {
return {
...createMissingAiAgentsStatus(),
@@ -90,9 +121,57 @@ const providers: AiModelProvider[] = [
},
]
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
aliases: [],
archived: false,
belongsTo: [],
color: null,
createdAt: 1700000000,
favorite: false,
favoriteIndex: null,
fileSize: 100,
filename: 'active.md',
hasH1: false,
icon: null,
isA: 'Note',
listPropertiesDisplay: [],
modifiedAt: 1700000000,
order: null,
organized: false,
outgoingLinks: [],
path: '/tmp/vault/active.md',
properties: {},
relatedTo: [],
relationships: {},
sidebarLabel: null,
snippet: '',
sort: null,
status: null,
template: null,
title: 'Active',
view: null,
visible: null,
wordCount: 0,
...overrides,
}
}
function contextReadyControllerCalls(): unknown[] {
return controllerCalls.filter((args) => {
const call = args as { activeEntry?: unknown; entries?: unknown[] }
return call.activeEntry !== null && call.activeEntry !== undefined && Array.isArray(call.entries)
})
}
describe('AiWorkspace', () => {
beforeEach(() => {
mockedAgentStatus = 'idle'
mockMessages = []
controllerCalls = []
generateTitleMock.mockReset()
generateTitleMock.mockResolvedValue('Quarterly sponsor outreach')
localStorage.clear()
resetVaultConfigStore()
})
@@ -111,20 +190,156 @@ describe('AiWorkspace', () => {
const workspace = screen.getByTestId('ai-workspace')
expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'docked')
expect(workspace).toHaveStyle({ width: '560px' })
expect(workspace.className).toContain('bottom-[30px]')
expect(workspace.className).not.toContain('shadow')
expect(screen.getByTestId('ai-panel-view')).toHaveAttribute('data-show-header', 'false')
expect(screen.queryByText('Agents')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
expect(screen.getByText('Agents')).toBeTruthy()
expect(screen.queryByText('AI Agent')).toBeNull()
expect(screen.queryByText('Idle')).toBeNull()
fireEvent.click(screen.getByTestId('ai-workspace-sidebar-new-chat'))
expect(screen.getAllByText('Chat 1').length).toBeGreaterThan(0)
expect(screen.getAllByText('Chat 2').length).toBeGreaterThan(0)
expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0)
expect(screen.getAllByText('AI Chat 2').length).toBeGreaterThan(0)
})
it('renders side mode as an in-editor tabbed panel and expands in place', () => {
const onClose = vi.fn()
render(<AiWorkspace open mode="side" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={onClose} />)
const workspace = screen.getByTestId('ai-workspace')
expect(workspace).toHaveAttribute('data-ai-workspace-mode', 'side')
expect(workspace).toHaveAttribute('data-ai-workspace-expanded', 'false')
expect(workspace).not.toHaveClass('fixed')
expect(workspace).toHaveClass('bg-sidebar')
expect(workspace).toHaveStyle({ width: '320px', minWidth: '320px' })
const header = screen.getByTestId('ai-workspace-side-header')
const tabStrip = screen.getByTestId('ai-workspace-side-tabs')
expect(header).not.toHaveClass('border-b')
fireEvent.click(screen.getByRole('button', { name: 'Mock history scroll' }))
expect(header).toHaveClass('border-b')
expect(tabStrip).toHaveClass('overflow-x-auto')
expect(screen.getByRole('button', { name: 'AI Chat' })).toBeTruthy()
expect(
within(header).getByRole('button', { name: 'Expand AI workspace' }).compareDocumentPosition(
within(header).getByRole('button', { name: 'Close AI workspace' }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New chat' }))
expect(screen.getByRole('button', { name: 'AI Chat 2' })).toBeTruthy()
expect(
within(tabStrip).getByRole('button', { name: 'AI Chat 2' }).compareDocumentPosition(
within(tabStrip).getByRole('button', { name: 'New chat' }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Close AI Chat 2' }))
expect(screen.queryByRole('button', { name: 'AI Chat 2' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand AI workspace' }))
expect(workspace).toHaveAttribute('data-ai-workspace-expanded', 'true')
expect(workspace).toHaveClass('absolute')
expect(screen.getByRole('button', { name: 'Restore AI workspace panel' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Close AI workspace' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('opens AI settings from the composer controls', () => {
const onOpenAiSettings = vi.fn()
render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
vaultPath="/tmp/vault"
onClose={vi.fn()}
onOpenAiSettings={onOpenAiSettings}
/>,
)
fireEvent.click(screen.getByTestId('ai-workspace-composer-settings'))
expect(onOpenAiSettings).toHaveBeenCalledOnce()
})
it('reports the active side chat so reopening can restore it', () => {
const onActiveConversationChange = vi.fn()
const { unmount } = render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
conversationSettings={[
{ id: 'chat-a', title: 'Planning notes', target_id: null, archived: false },
{ id: 'chat-b', title: 'Follow-up draft', target_id: null, archived: false },
]}
initialActiveConversationId="chat-a"
vaultPath="/tmp/vault"
onActiveConversationChange={onActiveConversationChange}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Follow-up draft' }))
expect(onActiveConversationChange).toHaveBeenLastCalledWith('chat-b')
unmount()
render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
conversationSettings={[
{ id: 'chat-a', title: 'Planning notes', target_id: null, archived: false },
{ id: 'chat-b', title: 'Follow-up draft', target_id: null, archived: false },
]}
initialActiveConversationId="chat-b"
vaultPath="/tmp/vault"
onClose={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Follow-up draft' })).toHaveAttribute('aria-pressed', 'true')
})
it('renames a side chat from the tab on double-click', () => {
const onConversationSettingsChange = vi.fn()
render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
conversationSettings={[{ id: 'chat-a', title: 'AI Chat', target_id: null, archived: false }]}
vaultPath="/tmp/vault"
onClose={vi.fn()}
onConversationSettingsChange={onConversationSettingsChange}
/>,
)
fireEvent.doubleClick(screen.getByRole('button', { name: 'AI Chat' }))
const input = screen.getByLabelText('Rename chat')
fireEvent.change(input, { target: { value: 'Research thread' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(screen.getByRole('button', { name: 'Research thread' })).toBeTruthy()
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ id: 'chat-a', title: 'Research thread' }),
])
})
it('resizes the docked workspace from the left edge and the sidebar split', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
const workspace = screen.getByTestId('ai-workspace')
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 })
fireEvent.mouseMove(window, { clientX: 60, clientY: 20 })
fireEvent.mouseUp(window)
@@ -139,6 +354,47 @@ describe('AiWorkspace', () => {
expect(sidebar).toHaveStyle({ width: '188px' })
})
it('restores the last resized side panel width when reopened', () => {
const { unmount } = render(<AiWorkspace open mode="side" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
const workspace = screen.getByTestId('ai-workspace')
fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 })
fireEvent.mouseMove(window, { clientX: 40, clientY: 20 })
fireEvent.mouseUp(window)
expect(workspace).toHaveStyle({ width: '380px' })
unmount()
render(<AiWorkspace open mode="side" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
expect(screen.getByTestId('ai-workspace')).toHaveStyle({ width: '380px' })
})
it('separates the guidance warning from the header and uses a short restore action', () => {
const status: VaultAiGuidanceStatus = {
agentsState: 'missing',
claudeState: 'managed',
geminiState: 'managed',
canRestore: true,
}
render(
<AiWorkspace
open
mode="side"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
vaultAiGuidanceStatus={status}
vaultPath="/tmp/vault"
onClose={vi.fn()}
onRestoreVaultAiGuidance={vi.fn()}
/>,
)
expect(screen.getByText('Vault guidance needs attention: Tolaria guidance missing or broken')).toHaveClass('min-w-0')
expect(screen.getByRole('button', { name: 'Restore' })).toBeTruthy()
expect(screen.getByText('Vault guidance needs attention: Tolaria guidance missing or broken').parentElement).toHaveClass('border-y')
})
it('does not archive an empty chat', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
@@ -146,8 +402,8 @@ describe('AiWorkspace', () => {
expect(archiveButtons.every((button) => button.hasAttribute('disabled'))).toBe(true)
fireEvent.click(archiveButtons[0])
expect(screen.getAllByText('Chat 1').length).toBeGreaterThan(0)
expect(screen.queryByText('Chat 2')).toBeNull()
expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0)
expect(screen.queryByText('AI Chat 2')).toBeNull()
})
it('activates a visible chat when persisted settings start with an archived chat', () => {
@@ -170,6 +426,52 @@ describe('AiWorkspace', () => {
expect(screen.getByTestId('ai-workspace-session-archived-chat')).toHaveClass('hidden')
})
it('passes vault context only to the active conversation controller', () => {
const entries = [
makeEntry({ path: '/tmp/vault/active.md', filename: 'active.md', title: 'Active' }),
makeEntry({ path: '/tmp/vault/related.md', filename: 'related.md', title: 'Related' }),
]
const singleConversation = render(
<AiWorkspace
open
mode="docked"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
activeEntry={entries[0]}
entries={entries}
conversationSettings={[
{ id: 'active-chat', title: 'Live Chat', target_id: null, archived: false },
]}
vaultPath="/tmp/vault"
onClose={vi.fn()}
/>,
)
const activeContextCallCount = contextReadyControllerCalls().length
singleConversation.unmount()
controllerCalls = []
render(
<AiWorkspace
open
mode="docked"
aiAgentsStatus={installedStatuses()}
aiModelProviders={providers}
activeEntry={entries[0]}
entries={entries}
conversationSettings={[
{ id: 'active-chat', title: 'Live Chat', target_id: null, archived: false },
{ id: 'inactive-chat', title: 'Later Chat', target_id: null, archived: false },
]}
vaultPath="/tmp/vault"
onClose={vi.fn()}
/>,
)
expect(screen.getByTestId('ai-workspace-session-active-chat')).toHaveClass('flex')
expect(screen.getByTestId('ai-workspace-session-inactive-chat')).toHaveClass('hidden')
expect(contextReadyControllerCalls()).toHaveLength(activeContextCallCount)
})
it('shows grouped target choices without missing agents', async () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
@@ -190,20 +492,56 @@ describe('AiWorkspace', () => {
expect(within(menu).getByText('OpenAI · GPT-4.1')).toBeTruthy()
})
it('renames the first chat from the first prompt and stores conversation settings', () => {
it('marks the first chat active when a prompt is submitted', () => {
const onConversationSettingsChange = vi.fn()
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
fireEvent.click(screen.getByText('Send mocked prompt'))
expect(screen.getAllByText('Summarize Quarterly Sponsor Outreach').length).toBeGreaterThan(0)
expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0)
expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true)
expect(generateTitleMock).not.toHaveBeenCalled()
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ title: 'Summarize Quarterly Sponsor Outreach' }),
expect.objectContaining({ title: 'AI Chat' }),
])
})
it('renames a persisted default chat title from the first prompt', () => {
it('renames the first chat after the first assistant reply and stores conversation settings', async () => {
mockMessages = [{
userMessage: 'summarize quarterly sponsor outreach',
actions: [],
response: 'The next step is to follow up with the sponsor pipeline.',
id: 'msg-title',
}]
const onConversationSettingsChange = vi.fn()
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
await waitFor(() => {
expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0)
})
expect(generateTitleMock).toHaveBeenCalledWith(expect.objectContaining({
assistantResponse: 'The next step is to follow up with the sponsor pipeline.',
permissionMode: 'safe',
prompt: 'summarize quarterly sponsor outreach',
target: expect.objectContaining({ kind: 'agent', agent: 'claude_code' }),
targetReady: true,
vaultPath: '/tmp/vault',
}))
expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true)
await waitFor(() => {
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ title: 'Quarterly sponsor outreach' }),
])
})
})
it('renames a persisted default chat title after the first assistant reply', async () => {
mockMessages = [{
userMessage: 'summarize quarterly sponsor outreach',
actions: [],
response: 'The next step is to follow up with the sponsor pipeline.',
id: 'msg-title',
}]
const onConversationSettingsChange = vi.fn()
render(
<AiWorkspace
@@ -218,19 +556,25 @@ describe('AiWorkspace', () => {
/>,
)
fireEvent.click(screen.getByText('Send mocked prompt'))
expect(screen.getAllByText('Summarize Quarterly Sponsor Outreach').length).toBeGreaterThan(0)
expect(onConversationSettingsChange).toHaveBeenLastCalledWith([
expect.objectContaining({ id: 'stored-chat', title: 'Summarize Quarterly Sponsor Outreach' }),
])
await waitFor(() => {
expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0)
})
await waitFor(() => {
expect(onConversationSettingsChange.mock.calls.some(([settings]) => (
settings.some((setting) => (
setting.id === 'stored-chat'
&& setting.title === 'Quarterly sponsor outreach'
))
))).toBe(true)
})
})
it('allows a chat title to be renamed from the sidebar', () => {
const onConversationSettingsChange = vi.fn()
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
fireEvent.doubleClick(screen.getByRole('button', { name: /chat 1/i }))
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
fireEvent.doubleClick(screen.getByRole('button', { name: /^AI Chat$/i }))
const input = screen.getByLabelText('Rename chat')
fireEvent.change(input, { target: { value: 'Sponsor Plan' } })
fireEvent.keyDown(input, { key: 'Enter' })
@@ -241,12 +585,15 @@ describe('AiWorkspace', () => {
])
})
it('collapses the workspace sidebar from the sidebar header', () => {
it('opens with the workspace sidebar collapsed and expands from the sidebar header', () => {
render(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Collapse AI chat list' }))
expect(screen.queryByText('AI workspace')).toBeNull()
expect(screen.queryByText('Agents')).toBeNull()
expect(screen.getByRole('button', { name: 'Expand AI chat list' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' }))
expect(screen.getByText('Agents')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Collapse AI chat list' })).toBeTruthy()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
import { Archive, ArrowSquareIn, ArrowSquareOut, GearSix, WarningCircle, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { useDragRegion } from '../hooks/useDragRegion'
import { getVaultAiGuidanceSummary, vaultAiGuidanceNeedsRestore, type VaultAiGuidanceStatus } from '../lib/vaultAiGuidance'
import { translate, type AppLocale } from '../lib/i18n'
import type { AiConversation } from './aiWorkspaceConversations'
import type { AiWorkspaceMode } from './aiWorkspaceSizing'
export function GuidanceWarning({
locale,
onRestore,
status,
}: {
locale: AppLocale
onRestore?: () => void
status?: VaultAiGuidanceStatus
}) {
if (!status || !vaultAiGuidanceNeedsRestore(status)) return null
return (
<div className="flex shrink-0 items-center gap-2 border-y border-border bg-muted/50 px-3 py-2 text-[12px] text-muted-foreground">
<WarningCircle size={15} className="shrink-0 text-amber-600" />
<span className="min-w-0 flex-1">
{translate(locale, 'ai.workspace.guidanceWarning', { summary: getVaultAiGuidanceSummary(status) })}
</span>
{status.canRestore && onRestore && (
<Button type="button" variant="outline" size="xs" onClick={onRestore}>
{translate(locale, 'ai.workspace.restoreGuidance')}
</Button>
)}
</div>
)
}
export function WorkspaceHeader({
conversation,
archiveDisabled,
locale,
mode,
onArchive,
onClose,
onDock,
onOpenAiSettings,
onPopOut,
}: {
conversation: AiConversation
archiveDisabled: boolean
locale: AppLocale
mode: AiWorkspaceMode
onArchive: () => void
onClose: () => void
onDock?: () => void
onOpenAiSettings?: () => void
onPopOut?: (context?: { activeConversationId?: string }) => void
}) {
const { dragRegionRef } = useDragRegion<HTMLDivElement>()
return (
<div
ref={dragRegionRef}
className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-3"
data-testid="ai-workspace-chat-header"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="min-w-0 max-w-[260px]">
<div className="truncate text-[13px] font-semibold text-foreground">{conversation.title}</div>
</div>
</div>
<div className="flex items-center gap-1">
{onOpenAiSettings && (
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.settings')} title={translate(locale, 'ai.workspace.settings')} onClick={onOpenAiSettings}>
<GearSix size={16} />
</Button>
)}
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.archive')} title={translate(locale, 'ai.workspace.archive')} disabled={archiveDisabled} onClick={onArchive}>
<Archive size={16} />
</Button>
{mode === 'docked' ? (
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.popOut')} title={translate(locale, 'ai.workspace.popOut')} onClick={() => onPopOut?.({ activeConversationId: conversation.id })}>
<ArrowSquareOut size={16} />
</Button>
) : (
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.dock')} title={translate(locale, 'ai.workspace.dock')} onClick={onDock}>
<ArrowSquareIn size={16} />
</Button>
)}
<Button type="button" variant="ghost" size="icon-xs" aria-label={translate(locale, 'ai.workspace.close')} title={translate(locale, 'ai.workspace.close')} onClick={onClose}>
<X size={16} />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog'
import {
createAiAgentAvailability,
createMissingAiAgentsStatus,
type AiAgentsStatus,
} from '../lib/aiAgents'
import { AiWorkspaceFloatingButton } from './AiWorkspaceFloatingButton'
function createCodexReadyStatus(): AiAgentsStatus {
return {
...createMissingAiAgentsStatus(),
codex: createAiAgentAvailability('installed', '0.12.0'),
}
}
function renderButton({
statuses = createCodexReadyStatus(),
updateBannerVisible = false,
}: {
statuses?: AiAgentsStatus
updateBannerVisible?: boolean
} = {}) {
return render(
<TooltipProvider>
<AiWorkspaceFloatingButton
defaultAgent="codex"
statuses={statuses}
updateBannerVisible={updateBannerVisible}
onOpen={vi.fn()}
/>
</TooltipProvider>,
)
}
describe('AiWorkspaceFloatingButton', () => {
it('uses the normal bottom offset when no update banner is visible', () => {
renderButton()
expect(screen.getByTestId('ai-workspace-floating-button')).toHaveClass('bottom-11')
})
it('moves above the update banner when one is visible', () => {
renderButton({ updateBannerVisible: true })
expect(screen.getByTestId('ai-workspace-floating-button')).toHaveClass('bottom-[80px]')
})
it('uses the selected agent icon when that agent is installed', () => {
renderButton()
const iconImage = screen.getByTestId('ai-workspace-floating-button').querySelector('img')
expect(iconImage).toHaveAttribute('src', '/ai-agent-icons/codex.svg')
})
it('falls back to sparkles when no selected agent is available', () => {
renderButton({ statuses: createMissingAiAgentsStatus() })
expect(screen.getByTestId('ai-workspace-floating-button').querySelector('img')).toBeNull()
})
it('shows the AI panel shortcut in the tooltip', async () => {
renderButton()
act(() => {
fireEvent.focus(screen.getByTestId('ai-workspace-floating-button'))
})
const tooltip = await screen.findByRole('tooltip')
expect(tooltip).toHaveTextContent('Open the AI panel')
expect(tooltip).toHaveTextContent(getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat) ?? '')
})
})

View File

@@ -0,0 +1,88 @@
import { Sparkle } from '@phosphor-icons/react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../hooks/appCommandCatalog'
import {
isAiAgentInstalled,
type AiAgentId,
type AiAgentsStatus,
} from '../lib/aiAgents'
import {
resolveAiTarget,
type AiModelProvider,
type AiTarget,
} from '../lib/aiTargets'
import { translate, type AppLocale } from '../lib/i18n'
import type { Settings } from '../types'
import { AiAgentIcon } from './AiAgentIcon'
interface AiWorkspaceFloatingButtonProps {
defaultAgent: AiAgentId
defaultTarget?: string
locale?: AppLocale
providers?: AiModelProvider[]
statuses: AiAgentsStatus
updateBannerVisible?: boolean
onOpen: () => void
}
function selectedTargetForButton({
defaultAgent,
defaultTarget,
providers,
}: Pick<AiWorkspaceFloatingButtonProps, 'defaultAgent' | 'defaultTarget' | 'providers'>): AiTarget {
return resolveAiTarget({
default_ai_agent: defaultAgent,
default_ai_target: defaultTarget,
ai_model_providers: providers ?? [],
} as Settings)
}
function FloatingButtonIcon({
selectedTarget,
statuses,
}: {
selectedTarget: AiTarget
statuses: AiAgentsStatus
}) {
if (selectedTarget.kind === 'agent' && isAiAgentInstalled(statuses, selectedTarget.agent)) {
return <AiAgentIcon agent={selectedTarget.agent} size={24} />
}
return <Sparkle size={22} weight="regular" />
}
export function AiWorkspaceFloatingButton({
defaultAgent,
defaultTarget,
locale = 'en',
providers = [],
statuses,
updateBannerVisible = false,
onOpen,
}: AiWorkspaceFloatingButtonProps) {
const selectedTarget = selectedTargetForButton({ defaultAgent, defaultTarget, providers })
const label = translate(locale, 'editor.toolbar.openAi')
const shortcut = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewToggleAiChat)
return (
<ActionTooltip copy={{ label, shortcut }} side="top" align="end" sideOffset={10}>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'fixed right-5 z-30 size-12 rounded-full border border-border bg-background text-foreground shadow-[0_10px_28px_rgba(15,23,42,0.18),0_2px_8px_rgba(15,23,42,0.12)] hover:bg-background hover:text-foreground',
updateBannerVisible ? 'bottom-[80px]' : 'bottom-11',
)}
aria-label={label}
data-testid="ai-workspace-floating-button"
onClick={onOpen}
>
<span className="flex size-7 items-center justify-center leading-none">
<FloatingButtonIcon selectedTarget={selectedTarget} statuses={statuses} />
</span>
</Button>
</ActionTooltip>
)
}

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