Compare commits
458 Commits
alpha-v202
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634975e505 | ||
|
|
88f5d04b8c | ||
|
|
59408bbebd | ||
|
|
bc97ade133 | ||
|
|
eb6b58eb36 | ||
|
|
520b2c9799 | ||
|
|
d5ecd567d0 | ||
|
|
f88b359bb5 | ||
|
|
67ada08e05 | ||
|
|
6ce04eead6 | ||
|
|
b8e3d8193c | ||
|
|
9c09b5ad78 | ||
|
|
a6caa2b069 | ||
|
|
0f231048cf | ||
|
|
8a3d00e8c6 | ||
|
|
bfd175224a | ||
|
|
0df9c1d707 | ||
|
|
ccfa148014 | ||
|
|
dda4943425 | ||
|
|
be87c3864c | ||
|
|
982d6476d7 | ||
|
|
92c0b895dc | ||
|
|
75a8444b41 | ||
|
|
d347cda9e3 | ||
|
|
f5adbf9cf7 | ||
|
|
19059fbf81 | ||
|
|
1694e3ad07 | ||
|
|
e46613b82b | ||
|
|
38ea51627d | ||
|
|
e5fa40b826 | ||
|
|
ff4a78f964 | ||
|
|
9973d8d4e6 | ||
|
|
e7e33c479d | ||
|
|
69b9da6da6 | ||
|
|
71ef8325a0 | ||
|
|
582d1d25d6 | ||
|
|
ebf4545d46 | ||
|
|
3c483ba0e6 | ||
|
|
71629763ee | ||
|
|
417e37f1d1 | ||
|
|
a3e3192b66 | ||
|
|
86c503c1f9 | ||
|
|
21a47b4a77 | ||
|
|
64d961bd98 | ||
|
|
1b218f5250 | ||
|
|
dfb9f98b7a | ||
|
|
6a033cd2db | ||
|
|
b872b6148c | ||
|
|
c7b6832cb1 | ||
|
|
05a9aacd1e | ||
|
|
701ebe3f07 | ||
|
|
f49b8f60dc | ||
|
|
39615f49f3 | ||
|
|
3d7a0ee936 | ||
|
|
9faee02e04 | ||
|
|
dfff3a848b | ||
|
|
aaf0336733 | ||
|
|
5b906cb3a4 | ||
|
|
0a40f12a6a | ||
|
|
033759598a | ||
|
|
871b62fbd3 | ||
|
|
aa310b25df | ||
|
|
dbb12e96fe | ||
|
|
c795f118d0 | ||
|
|
d96d6efd57 | ||
|
|
67623d848b | ||
|
|
45d72297b3 | ||
|
|
4f86b97e8d | ||
|
|
b6dd6189a2 | ||
|
|
ba07bafc97 | ||
|
|
6d86770abf | ||
|
|
e23ba0ae27 | ||
|
|
7a97f24a0e | ||
|
|
20f34ab9d1 | ||
|
|
19b31cc96a | ||
|
|
e5ed863d5d | ||
|
|
4f7f5a31e4 | ||
|
|
439e2b7f66 | ||
|
|
048d27243d | ||
|
|
58e129cdbc | ||
|
|
713b486750 | ||
|
|
7d66e2f8e1 | ||
|
|
cd6847d59e | ||
|
|
74c9841ad7 | ||
|
|
ba41854f0e | ||
|
|
6532ee125a | ||
|
|
c9698b853e | ||
|
|
db16f2ff5e | ||
|
|
1e5d83d4a3 | ||
|
|
062de2656e | ||
|
|
65d14ebf6f | ||
|
|
0b5149ba8d | ||
|
|
c23a3c1caf | ||
|
|
44e2870697 | ||
|
|
802b6b77d9 | ||
|
|
bb09fcfd6f | ||
|
|
c8ac12f3dc | ||
|
|
fbfda2f15c | ||
|
|
c4c7737e83 | ||
|
|
c24c60b433 | ||
|
|
21532de941 | ||
|
|
f92efd72df | ||
|
|
6e41328f21 | ||
|
|
5b2b4cd569 | ||
|
|
494aeb9533 | ||
|
|
8272e68934 | ||
|
|
7ba20c1e83 | ||
|
|
3384b9acd0 | ||
|
|
8b36ff6b00 | ||
|
|
acb35cb5df | ||
|
|
a8bda0a11b | ||
|
|
4acbe17e1e | ||
|
|
09759d204c | ||
|
|
7a01ef6952 | ||
|
|
1c010ddc22 | ||
|
|
0df5c7882d | ||
|
|
2d138f1564 | ||
|
|
c9ee81c5dc | ||
|
|
04fa74e8d8 | ||
|
|
b42ed35596 | ||
|
|
75e1a82ff0 | ||
|
|
2af2156526 | ||
|
|
568ada8992 | ||
|
|
0987946cff | ||
|
|
81b03f05b6 | ||
|
|
ee71a00c6f | ||
|
|
adcaa8a387 | ||
|
|
4a03f1675e | ||
|
|
50c304fce5 | ||
|
|
aa4d4fd12e | ||
|
|
b31f0b57da | ||
|
|
d3a577b488 | ||
|
|
fbb0927401 | ||
|
|
687d6403a3 | ||
|
|
1738dd2405 | ||
|
|
8c286a4856 | ||
|
|
0331b40ae2 | ||
|
|
d53c3fa7e5 | ||
|
|
3c14d60be7 | ||
|
|
765a16a4e8 | ||
|
|
886ded9575 | ||
|
|
831f6eb72b | ||
|
|
45efb08b23 | ||
|
|
fdc7d8fd84 | ||
|
|
690ace2d1f | ||
|
|
a267a00716 | ||
|
|
536cab3491 | ||
|
|
e49af5855b | ||
|
|
9012cd901b | ||
|
|
0d6e3853f2 | ||
|
|
8754b1bc77 | ||
|
|
ec5751711f | ||
|
|
0995c90cc0 | ||
|
|
dede400296 | ||
|
|
65ff44eb50 | ||
|
|
2c06c8dcc2 | ||
|
|
bc56eec35e | ||
|
|
388d389ba4 | ||
|
|
a1a4cb50d0 | ||
|
|
a7ed9adaae | ||
|
|
f8f08f4160 | ||
|
|
c0afae2296 | ||
|
|
176f983dc9 | ||
|
|
01a87ed478 | ||
|
|
18b4f9d078 | ||
|
|
fd3afc2493 | ||
|
|
0ad207e318 | ||
|
|
2e40945c2d | ||
|
|
54d53b882f | ||
|
|
848d718373 | ||
|
|
ec83e9b839 | ||
|
|
ce859aa0e7 | ||
|
|
1f4889fe66 | ||
|
|
2a2ea1b8ef | ||
|
|
b2a50d9870 | ||
|
|
cca72a9d6a | ||
|
|
ef0e85b053 | ||
|
|
2624d32b50 | ||
|
|
9c42da5745 | ||
|
|
454acb5ded | ||
|
|
2a691f68db | ||
|
|
c4de154410 | ||
|
|
670aae9f2e | ||
|
|
f3b4e5e61d | ||
|
|
4c4f01af43 | ||
|
|
79d7eb9795 | ||
|
|
d1ed826714 | ||
|
|
2e8286d2b3 | ||
|
|
cb9ebaadad | ||
|
|
c295c9f2b5 | ||
|
|
a4b11089c0 | ||
|
|
6f29542528 | ||
|
|
dcd0d73848 | ||
|
|
0e8c8fb61a | ||
|
|
42bdd559ef | ||
|
|
3a6c97272d | ||
|
|
f53a3746d3 | ||
|
|
23145a05f5 | ||
|
|
5b86b0b50d | ||
|
|
dc1b1783b6 | ||
|
|
3dc520abea | ||
|
|
792b3009dd | ||
|
|
6ee7f219f5 | ||
|
|
3db1ed9dc5 | ||
|
|
7a7ef5d9ca | ||
|
|
4c42cf4d52 | ||
|
|
405b711e16 | ||
|
|
83705e26f5 | ||
|
|
434927160c | ||
|
|
ec2e04a464 | ||
|
|
a17ec689a3 | ||
|
|
eda4d380e0 | ||
|
|
f9b155ba6e | ||
|
|
4a62008b1f | ||
|
|
a08cee7965 | ||
|
|
a7c480af62 | ||
|
|
f4790d5763 | ||
|
|
ed52a9e800 | ||
|
|
2fcd808f09 | ||
|
|
9d15286f7d | ||
|
|
537bd0f3ec | ||
|
|
648241604f | ||
|
|
c6f3d9d945 | ||
|
|
65e54f108a | ||
|
|
58f8a774b5 | ||
|
|
0bf280d3ec | ||
|
|
d2d4746d5c | ||
|
|
91db7c0098 | ||
|
|
87908d982f | ||
|
|
40544fc551 | ||
|
|
f34fdb0289 | ||
|
|
e601a42f41 | ||
|
|
de627c2478 | ||
|
|
46d9aaed48 | ||
|
|
6b96b5caf6 | ||
|
|
595c48ca59 | ||
|
|
fd92ff5e9b | ||
|
|
a39b346a75 | ||
|
|
deac10caa2 | ||
|
|
0e1e0ff47a | ||
|
|
a645edbc19 | ||
|
|
48f1fb0b50 | ||
|
|
1aede09834 | ||
|
|
d51b76f992 | ||
|
|
8e66fb4c19 | ||
|
|
a2843a0cf3 | ||
|
|
38fb8a7370 | ||
|
|
be5a8bb300 | ||
|
|
ce040c75fd | ||
|
|
ced89a42bd | ||
|
|
20a960551f | ||
|
|
2e331315e8 | ||
|
|
d2c15b8469 | ||
|
|
2fe6c5b580 | ||
|
|
2a224f78f8 | ||
|
|
b85d54bb5d | ||
|
|
9c05e17d37 | ||
|
|
cf6ad71fff | ||
|
|
db82aee172 | ||
|
|
c72d833624 | ||
|
|
a3ca78fced | ||
|
|
e912ab9a9f | ||
|
|
49717dc671 | ||
|
|
d9a0a04ddb | ||
|
|
0561fe68b1 | ||
|
|
2f078fab11 | ||
|
|
98fde6571a | ||
|
|
a5c4b8fa20 | ||
|
|
1301653b81 | ||
|
|
ee90b05159 | ||
|
|
b6c11b1468 | ||
|
|
2fbdafa6f2 | ||
|
|
39f44b86aa | ||
|
|
cb3274cdbc | ||
|
|
76d8bba332 | ||
|
|
86853cf337 | ||
|
|
9a292d2476 | ||
|
|
4a22b02810 | ||
|
|
e4270cda3b | ||
|
|
9cb0de37a5 | ||
|
|
2a794cb373 | ||
|
|
324af6b271 | ||
|
|
926db0eeb5 | ||
|
|
b1a97834c9 | ||
|
|
da13cb38ac | ||
|
|
dbf8ba5f40 | ||
|
|
3e8a6e5d7c | ||
|
|
8141644729 | ||
|
|
0d88c75718 | ||
|
|
f8721f2a1b | ||
|
|
fb39c6679a | ||
|
|
f1bed131bf | ||
|
|
257cf6ed02 | ||
|
|
908daafaa1 | ||
|
|
d15437face | ||
|
|
30281f879d | ||
|
|
4ef6edfb10 | ||
|
|
918419e371 | ||
|
|
2704002973 | ||
|
|
9265b8f117 | ||
|
|
943fa854e1 | ||
|
|
a2f8ba72a2 | ||
|
|
1d546dde3d | ||
|
|
63877e61ec | ||
|
|
8896f1d451 | ||
|
|
f523099854 | ||
|
|
15f38f096f | ||
|
|
cdd2ddec6c | ||
|
|
292d3739df | ||
|
|
1136c1948e | ||
|
|
8406042db1 | ||
|
|
f07cbbe0eb | ||
|
|
768da9f955 | ||
|
|
f58e9d8930 | ||
|
|
133242eff5 | ||
|
|
aebd2bea4a | ||
|
|
60238fbe78 | ||
|
|
5336236e79 | ||
|
|
f9719f11b5 | ||
|
|
e4083ded1a | ||
|
|
65a89f102a | ||
|
|
5b34ec2980 | ||
|
|
e579979829 | ||
|
|
c7edc71a97 | ||
|
|
5fd8f8fb40 | ||
|
|
694419d442 | ||
|
|
e82fb2a5c7 | ||
|
|
39234dcf52 | ||
|
|
36e8a62284 | ||
|
|
d622b91b81 | ||
|
|
5a546b9a8b | ||
|
|
b8c1a094b6 | ||
|
|
c2c56cfca5 | ||
|
|
449a62e31f | ||
|
|
7d6fd5cc51 | ||
|
|
75ed6460ac | ||
|
|
a3096c596f | ||
|
|
6105c7527c | ||
|
|
92eee0d470 | ||
|
|
f14fda09d5 | ||
|
|
31d12d8cc8 | ||
|
|
6a002f0dc0 | ||
|
|
9d30d1165f | ||
|
|
b709acd58a | ||
|
|
080e3498a7 | ||
|
|
51010ca578 | ||
|
|
15bc3a4701 | ||
|
|
3ebfa642fa | ||
|
|
8207b87c33 | ||
|
|
602a099ac3 | ||
|
|
2315122c4a | ||
|
|
ab4fcdbaae | ||
|
|
a56cb8c287 | ||
|
|
b93e6d491a | ||
|
|
3152ff2c6e | ||
|
|
dd018cf98b | ||
|
|
d0d9d90096 | ||
|
|
944efada94 | ||
|
|
ef4ad256e3 | ||
|
|
c427cd2492 | ||
|
|
151e993ab7 | ||
|
|
60554d0b96 | ||
|
|
4929f11b6d | ||
|
|
3f4e5b585f | ||
|
|
cd49c81bd3 | ||
|
|
b9f3dc9e1a | ||
|
|
122aba7878 | ||
|
|
05896bde19 | ||
|
|
87a933f39c | ||
|
|
54c0efa3e4 | ||
|
|
bdde0c8943 | ||
|
|
a474303eb9 | ||
|
|
4701485ee5 | ||
|
|
135a8a0adf | ||
|
|
0f5d7d5340 | ||
|
|
d6b3c0aef3 | ||
|
|
f896c01829 | ||
|
|
c3cff0c461 | ||
|
|
8c7f9238cb | ||
|
|
38acebba7c | ||
|
|
622977aeb8 | ||
|
|
9ce1c6f854 | ||
|
|
46865638cd | ||
|
|
564ee0a387 | ||
|
|
11655c0283 | ||
|
|
c24fd30266 | ||
|
|
daa1e3e985 | ||
|
|
44c7f43e57 | ||
|
|
f2c83be4b3 | ||
|
|
6104402bfb | ||
|
|
b3744e3bf5 | ||
|
|
67c8598b87 | ||
|
|
52620c80dc | ||
|
|
7e386ff70c | ||
|
|
aba5550b8e | ||
|
|
309b816e6d | ||
|
|
f552d8abff | ||
|
|
9f6779e9f1 | ||
|
|
bddf5106bb | ||
|
|
ba5c165016 | ||
|
|
39415c350f | ||
|
|
499e481df8 | ||
|
|
843cedd14e | ||
|
|
05156ca793 | ||
|
|
2308b269c8 | ||
|
|
b7f482bf27 | ||
|
|
33d7404d6c | ||
|
|
0ecac2ab79 | ||
|
|
7c9f2b0dda | ||
|
|
24e317cfa6 | ||
|
|
66c183fa61 | ||
|
|
dacf9bb19b | ||
|
|
3c92c200ab | ||
|
|
61c3a3e21b | ||
|
|
2e7c94d159 | ||
|
|
98ade7411a | ||
|
|
18776341f8 | ||
|
|
57bbcdbaef | ||
|
|
42bb6c4b18 | ||
|
|
070cd3ac6d | ||
|
|
accc6270d3 | ||
|
|
941f12aa1b | ||
|
|
4710a2d0a9 | ||
|
|
65a421215a | ||
|
|
4ed0289989 | ||
|
|
4ad32faa7b | ||
|
|
c9623d5ca8 | ||
|
|
77286457bf | ||
|
|
5b8aa8da0d | ||
|
|
21b6d8984a | ||
|
|
11fc123492 | ||
|
|
d98fa94e14 | ||
|
|
add3c4075f | ||
|
|
fb0d550eb2 | ||
|
|
6adf7fd53b | ||
|
|
3b4d9599d2 | ||
|
|
bb1b18686f | ||
|
|
6dbcc334bf | ||
|
|
4944365ed7 | ||
|
|
c4c6bb6c57 | ||
|
|
35dd2dd47b | ||
|
|
89141bae14 | ||
|
|
2b9294f884 | ||
|
|
a756af54ec | ||
|
|
b01b156dfb | ||
|
|
2c9361d704 | ||
|
|
cea71a4fd9 | ||
|
|
8b73ef5836 | ||
|
|
a6a727a4c0 | ||
|
|
bf13eed3ab | ||
|
|
a27a70e552 | ||
|
|
ca92bf4f77 | ||
|
|
9fda0a67ec | ||
|
|
0ffb7c65a9 | ||
|
|
633d9f1496 | ||
|
|
ba3d413b94 | ||
|
|
f3286922ad | ||
|
|
b0b476c9e5 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.9
|
||||
AVERAGE_THRESHOLD=9.79
|
||||
HOTSPOT_THRESHOLD=10.0
|
||||
AVERAGE_THRESHOLD=9.92
|
||||
|
||||
@@ -12,3 +12,7 @@ VITE_SENTRY_DSN=
|
||||
# PostHog (https://posthog.com → Project → Settings → Project API Key)
|
||||
VITE_POSTHOG_KEY=
|
||||
VITE_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
|
||||
# Lara CLI (https://github.com/translated/lara-cli)
|
||||
LARA_ACCESS_KEY_ID=
|
||||
LARA_ACCESS_KEY_SECRET=
|
||||
|
||||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
custom: https://refactoring.fm/
|
||||
137
.github/scripts/prefetch-tauri-nsis.ps1
vendored
Normal file
137
.github/scripts/prefetch-tauri-nsis.ps1
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$nsisUrl = "https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip"
|
||||
$nsisSha1 = "EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D"
|
||||
$tauriUtilsUrl = "https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll"
|
||||
$tauriUtilsSha1 = "75197FEE3C6A814FE035788D1C34EAD39349B860"
|
||||
$tauriUtilsRelativePath = "Plugins\x86-unicode\additional\nsis_tauri_utils.dll"
|
||||
|
||||
$nsisRequiredFiles = @(
|
||||
"makensis.exe",
|
||||
"Bin\makensis.exe",
|
||||
"Stubs\lzma-x86-unicode",
|
||||
"Stubs\lzma_solid-x86-unicode",
|
||||
"Include\MUI2.nsh",
|
||||
"Include\FileFunc.nsh",
|
||||
"Include\x64.nsh",
|
||||
"Include\nsDialogs.nsh",
|
||||
"Include\WinMessages.nsh",
|
||||
"Include\Win\COM.nsh",
|
||||
"Include\Win\Propkey.nsh",
|
||||
"Include\Win\RestartManager.nsh"
|
||||
)
|
||||
|
||||
function Get-UpperSha1 {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
return (Get-FileHash -Algorithm SHA1 -LiteralPath $Path).Hash.ToUpperInvariant()
|
||||
}
|
||||
|
||||
function Test-FileSha1 {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$ExpectedSha1
|
||||
)
|
||||
|
||||
return (Test-Path -LiteralPath $Path) -and ((Get-UpperSha1 -Path $Path) -eq $ExpectedSha1)
|
||||
}
|
||||
|
||||
function Save-VerifiedDownload {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Uri,
|
||||
[Parameter(Mandatory = $true)][string]$OutFile,
|
||||
[Parameter(Mandatory = $true)][string]$ExpectedSha1
|
||||
)
|
||||
|
||||
$parent = Split-Path -Parent $OutFile
|
||||
New-Item -ItemType Directory -Force -Path $parent | Out-Null
|
||||
|
||||
$tempFile = "$OutFile.download"
|
||||
for ($attempt = 1; $attempt -le 5; $attempt++) {
|
||||
try {
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
|
||||
Invoke-WebRequest -Uri $Uri -OutFile $tempFile -TimeoutSec 120
|
||||
|
||||
$actualSha1 = Get-UpperSha1 -Path $tempFile
|
||||
if ($actualSha1 -ne $ExpectedSha1) {
|
||||
throw "SHA1 mismatch for $Uri. Expected $ExpectedSha1, got $actualSha1."
|
||||
}
|
||||
|
||||
Move-Item -Force -LiteralPath $tempFile -Destination $OutFile
|
||||
return
|
||||
} catch {
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
|
||||
if ($attempt -eq 5) {
|
||||
throw
|
||||
}
|
||||
|
||||
$delaySeconds = [Math]::Min(30, 5 * $attempt)
|
||||
Write-Warning "Download attempt ${attempt} failed: $($_.Exception.Message). Retrying in ${delaySeconds}s."
|
||||
Start-Sleep -Seconds $delaySeconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Find-MissingFile {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Root,
|
||||
[Parameter(Mandatory = $true)][string[]]$RelativePaths
|
||||
)
|
||||
|
||||
foreach ($relativePath in $RelativePaths) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath))) {
|
||||
return $relativePath
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||||
throw "LOCALAPPDATA is required to resolve Tauri's Windows tool cache."
|
||||
}
|
||||
|
||||
$tauriToolsPath = Join-Path $env:LOCALAPPDATA "tauri"
|
||||
$nsisPath = Join-Path $tauriToolsPath "NSIS"
|
||||
$downloadRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
|
||||
[System.IO.Path]::GetTempPath()
|
||||
} else {
|
||||
$env:RUNNER_TEMP
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $tauriToolsPath | Out-Null
|
||||
|
||||
$missingNsisFile = Find-MissingFile -Root $nsisPath -RelativePaths $nsisRequiredFiles
|
||||
if ($missingNsisFile) {
|
||||
Write-Host "Tauri NSIS cache is missing $missingNsisFile; downloading NSIS 3.11."
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $nsisPath
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $tauriToolsPath "nsis-3.11")
|
||||
|
||||
$zipPath = Join-Path $downloadRoot "nsis-3.11.zip"
|
||||
Save-VerifiedDownload -Uri $nsisUrl -OutFile $zipPath -ExpectedSha1 $nsisSha1
|
||||
Expand-Archive -Force -LiteralPath $zipPath -DestinationPath $tauriToolsPath
|
||||
|
||||
$extractedNsisPath = Join-Path $tauriToolsPath "nsis-3.11"
|
||||
if (-not (Test-Path -LiteralPath $extractedNsisPath)) {
|
||||
throw "Downloaded NSIS archive did not contain the expected nsis-3.11 directory."
|
||||
}
|
||||
|
||||
Move-Item -Force -LiteralPath $extractedNsisPath -Destination $nsisPath
|
||||
} else {
|
||||
Write-Host "Tauri NSIS cache already contains NSIS 3.11."
|
||||
}
|
||||
|
||||
$tauriUtilsPath = Join-Path $nsisPath $tauriUtilsRelativePath
|
||||
if (-not (Test-FileSha1 -Path $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1)) {
|
||||
Write-Host "Downloading Tauri NSIS utility plugin."
|
||||
Save-VerifiedDownload -Uri $tauriUtilsUrl -OutFile $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1
|
||||
} else {
|
||||
Write-Host "Tauri NSIS utility plugin is already cached."
|
||||
}
|
||||
|
||||
$missingFile = Find-MissingFile -Root $nsisPath -RelativePaths ($nsisRequiredFiles + @($tauriUtilsRelativePath))
|
||||
if ($missingFile) {
|
||||
throw "Tauri NSIS toolchain is incomplete after prefetch; missing $missingFile."
|
||||
}
|
||||
|
||||
Write-Host "Tauri NSIS toolchain ready at $nsisPath."
|
||||
166
.github/workflows/ci.yml
vendored
166
.github/workflows/ci.yml
vendored
@@ -14,10 +14,12 @@ permissions:
|
||||
env:
|
||||
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
|
||||
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
|
||||
# Keep large production frontend builds below CI runner memory limits.
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests & Quality Checks
|
||||
frontend-quality:
|
||||
name: Frontend Tests & Quality Checks
|
||||
runs-on: macos-15
|
||||
|
||||
steps:
|
||||
@@ -36,28 +38,11 @@ jobs:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy, llvm-tools-preview
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Keep frontend and Rust quality gates in separate macOS jobs so the
|
||||
# expensive Rust target cache restore no longer blocks the frontend lane.
|
||||
# ── 0. Build check (catches type errors and bundler failures) ─────────
|
||||
- name: TypeScript type check
|
||||
run: pnpm exec tsc --noEmit
|
||||
@@ -65,40 +50,25 @@ jobs:
|
||||
- name: Vite build check
|
||||
run: pnpm build
|
||||
|
||||
# ── 1. Tests ──────────────────────────────────────────────────────────
|
||||
- name: Run frontend tests
|
||||
run: pnpm test
|
||||
|
||||
# ── 1. Coverage-backed tests ──────────────────────────────────────────
|
||||
# The coverage command runs the canonical frontend test suite.
|
||||
- name: Bundle MCP server resources (required by Tauri build)
|
||||
run: node scripts/bundle-mcp-server.mjs
|
||||
|
||||
- name: Run Rust tests
|
||||
run: cargo test --manifest-path=src-tauri/Cargo.toml
|
||||
|
||||
# ── 2. Coverage (enforced — fails build if thresholds not met) ────────
|
||||
- name: Frontend coverage (≥70% lines/functions/branches/statements)
|
||||
# ── 2. Tests + coverage (enforced — fails build if thresholds not met) ─
|
||||
- name: Frontend tests + coverage (≥70% lines/functions/branches/statements)
|
||||
run: pnpm test:coverage
|
||||
# Thresholds configured in vite.config.ts — exits non-zero if coverage drops
|
||||
|
||||
- name: Rust coverage (≥85% lines)
|
||||
run: |
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
|
||||
--lcov \
|
||||
--output-path coverage/rust.lcov \
|
||||
--fail-under-lines 85
|
||||
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
|
||||
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
- name: Upload frontend coverage to Codecov
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
disable_search: true
|
||||
files: ./coverage/lcov.info,./coverage/rust.lcov
|
||||
files: ./coverage/lcov.info
|
||||
flags: frontend
|
||||
verbose: true
|
||||
# OIDC avoids long-lived CODECOV_TOKEN secrets.
|
||||
|
||||
@@ -162,8 +132,118 @@ jobs:
|
||||
- name: Lint frontend
|
||||
run: pnpm lint
|
||||
|
||||
rust-quality:
|
||||
name: Rust Tests & Quality Checks
|
||||
runs-on: macos-15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy, llvm-tools-preview
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Rust tests + coverage (≥85% lines)
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
cargo llvm-cov \
|
||||
--manifest-path src-tauri/Cargo.toml \
|
||||
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
|
||||
--lcov \
|
||||
--output-path coverage/rust.lcov \
|
||||
--fail-under-lines 85
|
||||
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
|
||||
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
|
||||
|
||||
- name: Upload Rust coverage to Codecov
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
use_oidc: true
|
||||
fail_ci_if_error: true
|
||||
disable_search: true
|
||||
files: ./coverage/rust.lcov
|
||||
flags: rust
|
||||
verbose: true
|
||||
# OIDC avoids long-lived CODECOV_TOKEN secrets.
|
||||
|
||||
- name: Clippy (Rust)
|
||||
run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
- name: Format check (Rust)
|
||||
run: cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
|
||||
|
||||
linux-build:
|
||||
name: Linux build verification
|
||||
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 \
|
||||
librsvg2-dev \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
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@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
src-tauri/target
|
||||
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Frontend build
|
||||
run: pnpm build
|
||||
|
||||
- name: Cargo check
|
||||
run: cargo check --manifest-path=src-tauri/Cargo.toml
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
438
.github/workflows/release-stable.yml
vendored
438
.github/workflows/release-stable.yml
vendored
@@ -4,10 +4,14 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- '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 }}
|
||||
@@ -34,14 +38,24 @@ jobs:
|
||||
from datetime import date
|
||||
|
||||
tag = os.environ["GITHUB_REF_NAME"]
|
||||
version = tag.removeprefix("stable-v")
|
||||
match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})", version)
|
||||
if not match:
|
||||
raise SystemExit(f"Stable tags must use stable-vYYYY.M.D, got {tag}")
|
||||
legacy_match = re.fullmatch(r"stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})", tag)
|
||||
date_match = re.fullmatch(r"v(\d{4})-(\d{2})-(\d{2})", tag)
|
||||
|
||||
if date_match:
|
||||
year, month, day = map(int, date_match.groups())
|
||||
date(year, month, day)
|
||||
version = f"{year}.{month}.{day}"
|
||||
display_version = tag
|
||||
elif legacy_match:
|
||||
year, month, day = map(int, legacy_match.groups())
|
||||
date(year, month, day)
|
||||
version = f"{year}.{month}.{day}"
|
||||
display_version = version
|
||||
else:
|
||||
raise SystemExit(f"Stable tags must use vYYYY-MM-DD or stable-vYYYY.M.D, got {tag}")
|
||||
|
||||
date(*map(int, match.groups()))
|
||||
print(f"version={version}")
|
||||
print(f"display_version={version}")
|
||||
print(f"display_version={display_version}")
|
||||
print(f"tag={tag}")
|
||||
PY
|
||||
|
||||
@@ -50,7 +64,7 @@ jobs:
|
||||
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Phase 2: Build each architecture in parallel
|
||||
# Phase 2: Build release bundles in parallel
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: Build (${{ matrix.arch }})
|
||||
@@ -62,6 +76,8 @@ jobs:
|
||||
include:
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -227,6 +243,7 @@ jobs:
|
||||
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 }}
|
||||
@@ -249,12 +266,258 @@ jobs:
|
||||
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 \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
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@stable
|
||||
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:
|
||||
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
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
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 [ ${#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@v4
|
||||
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@stable
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
name: GitHub Release (stable)
|
||||
needs: [version, build]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -266,26 +529,81 @@ jobs:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Normalize macOS release artifact names
|
||||
run: |
|
||||
normalize_macos_artifacts() {
|
||||
local arch="$1"
|
||||
local normalized_updater="$2"
|
||||
local normalized_dmg="$3"
|
||||
local updater_dir="updater-${arch}"
|
||||
local updater_file
|
||||
updater_file=$(find "$updater_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
|
||||
if [ -z "$updater_file" ]; then
|
||||
echo "::error::Missing macOS updater artifact in ${updater_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local sig_file="${updater_file}.sig"
|
||||
if [ ! -f "$sig_file" ]; then
|
||||
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local normalized_sig="${normalized_updater}.sig"
|
||||
if [ "$updater_file" != "$normalized_updater" ]; then
|
||||
mv "$updater_file" "$normalized_updater"
|
||||
fi
|
||||
if [ "$sig_file" != "$normalized_sig" ]; then
|
||||
mv "$sig_file" "$normalized_sig"
|
||||
fi
|
||||
|
||||
local dmg_dir="dmg-${arch}"
|
||||
local dmg_file
|
||||
dmg_file=$(find "$dmg_dir" -maxdepth 1 -name "*.dmg" -print -quit)
|
||||
if [ -z "$dmg_file" ]; then
|
||||
echo "::error::Missing macOS DMG artifact in ${dmg_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$dmg_file" != "$normalized_dmg" ]; then
|
||||
mv "$dmg_file" "$normalized_dmg"
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_macos_artifacts aarch64 \
|
||||
"updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz" \
|
||||
"dmg-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.dmg"
|
||||
normalize_macos_artifacts x86_64 \
|
||||
"updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz" \
|
||||
"dmg-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.dmg"
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
PREV_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
NOTES=$(git log --oneline --no-merges -20)
|
||||
NOTES_FILE="release-notes/${{ needs.version.outputs.tag }}.md"
|
||||
if [ -f "$NOTES_FILE" ]; then
|
||||
cat "$NOTES_FILE" > release_notes.md
|
||||
else
|
||||
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
|
||||
PREV_TAG=$(git for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags/v20* refs/tags/stable-v* | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
NOTES=$(git log --oneline --no-merges -20)
|
||||
else
|
||||
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
|
||||
fi
|
||||
{
|
||||
echo "## What's Changed"
|
||||
echo ""
|
||||
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
|
||||
} > release_notes.md
|
||||
fi
|
||||
{
|
||||
echo "## What's Changed"
|
||||
echo ""
|
||||
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "**Stable release — manually promoted from \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo "**Includes macOS (Apple Silicon and Intel), Windows x64, and Linux x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
} >> release_notes.md
|
||||
|
||||
- name: Build stable-latest.json
|
||||
run: |
|
||||
@@ -295,9 +613,42 @@ jobs:
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
|
||||
find_required() {
|
||||
local patterns=("$@")
|
||||
for pattern in "${patterns[@]}"; do
|
||||
set -- $pattern
|
||||
if [ -e "$1" ]; then
|
||||
printf '%s\n' "$1"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "::error::Missing required artifact matching one of: ${patterns[*]}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
|
||||
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
|
||||
ARM_SIG=$(cat "$ARM_SIG_FILE")
|
||||
ARM_TARBALL=$(basename "$ARM_UPDATER_FILE")
|
||||
ARM_DMG=$(basename "$(find_required "dmg-aarch64/*.dmg")")
|
||||
|
||||
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
|
||||
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
|
||||
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
|
||||
INTEL_TARBALL=$(basename "$INTEL_UPDATER_FILE")
|
||||
INTEL_DMG=$(basename "$(find_required "dmg-x86_64/*.dmg")")
|
||||
|
||||
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
|
||||
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
|
||||
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
|
||||
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
|
||||
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
|
||||
|
||||
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
|
||||
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
|
||||
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
|
||||
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
|
||||
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
|
||||
|
||||
cat > stable-latest.json << EOF
|
||||
{
|
||||
@@ -309,6 +660,21 @@ jobs:
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}",
|
||||
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "${INTEL_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_TARBALL}",
|
||||
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_DMG}"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "${LINUX_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "${WINDOWS_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,6 +693,35 @@ jobs:
|
||||
dmg-aarch64/*.dmg
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
dmg-x86_64/*.dmg
|
||||
updater-x86_64/*.app.tar.gz
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.rpm
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.rpm
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
windows-x86_64-bundles/*/*.exe
|
||||
windows-x86_64-bundles/*/*.exe.sig
|
||||
windows-x86_64-bundles/*/*.msi
|
||||
windows-x86_64-bundles/*/*.msi.sig
|
||||
windows-x86_64-bundles/*/*.zip
|
||||
windows-x86_64-bundles/*/*.zip.sig
|
||||
stable-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -353,7 +748,8 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p _site/alpha _site/stable
|
||||
mkdir -p _site/alpha _site/stable _site/release-notes
|
||||
if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi
|
||||
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
|
||||
383
.github/workflows/release.yml
vendored
383
.github/workflows/release.yml
vendored
@@ -8,6 +8,9 @@ on:
|
||||
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 }}
|
||||
@@ -69,10 +72,18 @@ jobs:
|
||||
else:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
stable_date = None
|
||||
stable_pattern = re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$")
|
||||
stable_patterns = (
|
||||
re.compile(r"^v(\d{4})-(\d{2})-(\d{2})$"),
|
||||
re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$"),
|
||||
)
|
||||
|
||||
for stable_tag in lines(["git", "tag", "--list", "stable-v*", "--sort=-version:refname"]):
|
||||
match = stable_pattern.fullmatch(stable_tag)
|
||||
stable_tags = lines([
|
||||
"git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)",
|
||||
"refs/tags/v20*", "refs/tags/stable-v*",
|
||||
])
|
||||
|
||||
for stable_tag in stable_tags:
|
||||
match = next((pattern.fullmatch(stable_tag) for pattern in stable_patterns if pattern.fullmatch(stable_tag)), None)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
@@ -121,6 +132,8 @@ jobs:
|
||||
include:
|
||||
- arch: aarch64
|
||||
target: aarch64-apple-darwin
|
||||
- arch: x86_64
|
||||
target: x86_64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -286,6 +299,7 @@ jobs:
|
||||
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 }}
|
||||
@@ -303,13 +317,258 @@ jobs:
|
||||
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 \
|
||||
librsvg2-dev \
|
||||
curl \
|
||||
wget \
|
||||
patchelf \
|
||||
build-essential \
|
||||
file \
|
||||
rpm
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
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@stable
|
||||
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:
|
||||
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
|
||||
installers=(
|
||||
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
|
||||
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 [ ${#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@v4
|
||||
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@stable
|
||||
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]
|
||||
needs: [version, build, build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -321,6 +580,37 @@ jobs:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Normalize macOS updater artifact names
|
||||
run: |
|
||||
normalize_updater() {
|
||||
local arch="$1"
|
||||
local normalized_updater="$2"
|
||||
local artifact_dir="updater-${arch}"
|
||||
local updater_file
|
||||
updater_file=$(find "$artifact_dir" -maxdepth 1 -name "*.app.tar.gz" -print -quit)
|
||||
if [ -z "$updater_file" ]; then
|
||||
echo "::error::Missing macOS updater artifact in ${artifact_dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local sig_file="${updater_file}.sig"
|
||||
if [ ! -f "$sig_file" ]; then
|
||||
echo "::error::Missing macOS updater signature for ${updater_file}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local normalized_sig="${normalized_updater}.sig"
|
||||
if [ "$updater_file" != "$normalized_updater" ]; then
|
||||
mv "$updater_file" "$normalized_updater"
|
||||
fi
|
||||
if [ "$sig_file" != "$normalized_sig" ]; then
|
||||
mv "$sig_file" "$normalized_sig"
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_updater aarch64 "updater-aarch64/Tolaria_${{ needs.version.outputs.version }}_macOS_Silicon.app.tar.gz"
|
||||
normalize_updater x86_64 "updater-x86_64/Tolaria_${{ needs.version.outputs.version }}_macOS_Intel.app.tar.gz"
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
PREV_TAG=$(python3 <<'PY'
|
||||
@@ -357,7 +647,7 @@ jobs:
|
||||
echo "---"
|
||||
echo "**Alpha build — updated on every push to \`main\`**"
|
||||
echo ""
|
||||
echo "**Requires Apple Silicon (M1/M2/M3)**"
|
||||
echo "**Includes macOS (Apple Silicon and Intel), Linux x64, and Windows x64 bundles**"
|
||||
echo ""
|
||||
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
|
||||
} > release_notes.md
|
||||
@@ -370,8 +660,38 @@ jobs:
|
||||
REPO_NAME="${REPO#*/}"
|
||||
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
|
||||
|
||||
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
|
||||
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
|
||||
find_required() {
|
||||
for pattern in "$@"; do
|
||||
set -- $pattern
|
||||
if [ -e "$1" ]; then
|
||||
printf '%s\n' "$1"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ARM_SIG_FILE=$(find_required "updater-aarch64/*.app.tar.gz.sig")
|
||||
ARM_UPDATER_FILE="${ARM_SIG_FILE%.sig}"
|
||||
ARM_SIG=$(cat "$ARM_SIG_FILE")
|
||||
ARM_UPDATER=$(basename "$ARM_UPDATER_FILE")
|
||||
|
||||
INTEL_SIG_FILE=$(find_required "updater-x86_64/*.app.tar.gz.sig")
|
||||
INTEL_UPDATER_FILE="${INTEL_SIG_FILE%.sig}"
|
||||
INTEL_SIG=$(cat "$INTEL_SIG_FILE")
|
||||
INTEL_UPDATER=$(basename "$INTEL_UPDATER_FILE")
|
||||
|
||||
LINUX_SIG_FILE=$(find_required "linux-x86_64-bundles/*/*.AppImage.sig" "linux-x86_64-bundles/*/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*/*.deb.sig" "linux-x86_64-bundles/*.AppImage.sig" "linux-x86_64-bundles/*.AppImage.tar.gz.sig" "linux-x86_64-bundles/*.deb.sig")
|
||||
LINUX_UPDATER_FILE="${LINUX_SIG_FILE%.sig}"
|
||||
LINUX_SIG=$(cat "$LINUX_SIG_FILE")
|
||||
LINUX_UPDATER=$(basename "$LINUX_UPDATER_FILE")
|
||||
LINUX_DOWNLOAD=$(basename "$(find_required "linux-x86_64-bundles/*/*.AppImage" "linux-x86_64-bundles/*/*.deb" "linux-x86_64-bundles/*/*.AppImage.tar.gz" "linux-x86_64-bundles/*.AppImage" "linux-x86_64-bundles/*.deb" "linux-x86_64-bundles/*.AppImage.tar.gz")")
|
||||
|
||||
WINDOWS_SIG_FILE=$(find_required "windows-x86_64-bundles/*/*-setup.exe.sig" "windows-x86_64-bundles/*/*.msi.sig" "windows-x86_64-bundles/*/*.nsis.zip.sig" "windows-x86_64-bundles/*/*.msi.zip.sig" "windows-x86_64-bundles/*-setup.exe.sig" "windows-x86_64-bundles/*.msi.sig" "windows-x86_64-bundles/*.nsis.zip.sig" "windows-x86_64-bundles/*.msi.zip.sig")
|
||||
WINDOWS_UPDATER_FILE="${WINDOWS_SIG_FILE%.sig}"
|
||||
WINDOWS_SIG=$(cat "$WINDOWS_SIG_FILE")
|
||||
WINDOWS_UPDATER=$(basename "$WINDOWS_UPDATER_FILE")
|
||||
WINDOWS_DOWNLOAD=$(basename "$(find_required "windows-x86_64-bundles/*/*-setup.exe" "windows-x86_64-bundles/*/*.msi" "windows-x86_64-bundles/*/*.nsis.zip" "windows-x86_64-bundles/*/*.msi.zip" "windows-x86_64-bundles/*-setup.exe" "windows-x86_64-bundles/*.msi" "windows-x86_64-bundles/*.nsis.zip" "windows-x86_64-bundles/*.msi.zip")")
|
||||
|
||||
cat > alpha-latest.json << EOF
|
||||
{
|
||||
@@ -381,7 +701,23 @@ jobs:
|
||||
"platforms": {
|
||||
"darwin-aarch64": {
|
||||
"signature": "${ARM_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_UPDATER}"
|
||||
},
|
||||
"darwin-x86_64": {
|
||||
"signature": "${INTEL_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${INTEL_UPDATER}"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"signature": "${LINUX_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${LINUX_DOWNLOAD}"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"signature": "${WINDOWS_SIG}",
|
||||
"url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_UPDATER}",
|
||||
"download_url": "https://github.com/${REPO}/releases/download/${TAG}/${WINDOWS_DOWNLOAD}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -399,6 +735,34 @@ jobs:
|
||||
files: |
|
||||
updater-aarch64/*.app.tar.gz
|
||||
updater-aarch64/*.app.tar.gz.sig
|
||||
updater-x86_64/*.app.tar.gz
|
||||
updater-x86_64/*.app.tar.gz.sig
|
||||
linux-x86_64-bundles/*.deb
|
||||
linux-x86_64-bundles/*.deb.sig
|
||||
linux-x86_64-bundles/*.rpm
|
||||
linux-x86_64-bundles/*.AppImage
|
||||
linux-x86_64-bundles/*.AppImage.sig
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*.AppImage.tar.gz.sig
|
||||
linux-x86_64-bundles/*/*.deb
|
||||
linux-x86_64-bundles/*/*.deb.sig
|
||||
linux-x86_64-bundles/*/*.rpm
|
||||
linux-x86_64-bundles/*/*.AppImage
|
||||
linux-x86_64-bundles/*/*.AppImage.sig
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz
|
||||
linux-x86_64-bundles/*/*.AppImage.tar.gz.sig
|
||||
windows-x86_64-bundles/*.exe
|
||||
windows-x86_64-bundles/*.exe.sig
|
||||
windows-x86_64-bundles/*.msi
|
||||
windows-x86_64-bundles/*.msi.sig
|
||||
windows-x86_64-bundles/*.zip
|
||||
windows-x86_64-bundles/*.zip.sig
|
||||
windows-x86_64-bundles/*/*.exe
|
||||
windows-x86_64-bundles/*/*.exe.sig
|
||||
windows-x86_64-bundles/*/*.msi
|
||||
windows-x86_64-bundles/*/*.msi.sig
|
||||
windows-x86_64-bundles/*/*.zip
|
||||
windows-x86_64-bundles/*/*.zip.sig
|
||||
alpha-latest.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -425,7 +789,8 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p _site/alpha _site/stable
|
||||
mkdir -p _site/alpha _site/stable _site/release-notes
|
||||
if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi
|
||||
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
|
||||
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -70,5 +70,9 @@ CODE-HEALTH-REPORT.md
|
||||
*.key.pub
|
||||
|
||||
# Local environment variables (never commit)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Local Codacy CLI runtime/config generated by the MCP server
|
||||
.codacy/
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -22,7 +22,7 @@ Run `/laputa-next-task` — fetches next task (To Rework first, then Open), move
|
||||
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
|
||||
- Commit every 20–30 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
|
||||
- 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
|
||||
|
||||
@@ -47,16 +47,14 @@ bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
|
||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||
|
||||
After both phases pass, add a **completion comment** to the Todoist task before running `/laputa-done`. The comment must include:
|
||||
- What was implemented (1–2 lines)
|
||||
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)
|
||||
- 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")
|
||||
- Code health: final Hotspot and Average scores after push
|
||||
|
||||
Then run `/laputa-done <task_id>` → moves to In Review, notifies Brian, self-dispatches next task.
|
||||
|
||||
---
|
||||
|
||||
## 2. Development Process
|
||||
@@ -73,6 +71,22 @@ Red → Green → Refactor → Commit. One cycle per commit. For bugs: write fai
|
||||
|
||||
**Test quality (Kent Beck's Desiderata):** Isolated · Deterministic · Fast · Behavioral · Structure-insensitive · Specific · Predictive. Fix flaky tests first. Prefer E2E over unit tests for user flows.
|
||||
|
||||
### Localization (mandatory for UI copy)
|
||||
|
||||
All user-facing UI labels/copy must live in `src/lib/locales/en.json` and be translated into every target listed in `lara.yaml`. When adding or changing interface copy:
|
||||
|
||||
```bash
|
||||
pnpm l10n:translate
|
||||
```
|
||||
|
||||
Use `pnpm l10n:translate:force` only when intentionally regenerating existing translations. Commit `src/lib/locales/*.json`, `lara.yaml`/`lara.lock` changes if produced, and verify placeholders/product names stayed intact.
|
||||
|
||||
### Product analytics (mandatory for meaningful features)
|
||||
|
||||
New features should almost always emit a PostHog event so we can see whether users actually discover and use them. Skip instrumentation only for very small changes where a dedicated event would create noise. Use clear, stable event names, avoid PII or note content, and include only safe metadata that helps evaluate adoption and failures.
|
||||
|
||||
When adding or changing a meaningful user-facing feature, include the event name(s) in the Todoist completion comment alongside QA, docs, and code health. If intentionally not instrumenting a feature, explain why in the completion comment.
|
||||
|
||||
### Code health (mandatory)
|
||||
|
||||
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
|
||||
@@ -122,10 +136,6 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
|
||||
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
|
||||
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
|
||||
|
||||
### UI design
|
||||
|
||||
Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task; on completion merge into `ui-design.pen` and delete it.
|
||||
|
||||
### UI components — mandatory rules
|
||||
|
||||
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
|
||||
@@ -155,10 +165,11 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
|
||||
- Tauri menu accelerators: `MenuItemBuilder::new(label).accelerator("CmdOrCtrl+1")`
|
||||
- `app.set_menu()` replaces the ENTIRE menu bar — include all submenus
|
||||
- `mock-tauri.ts` silently swallows Tauri calls — not a substitute for native testing
|
||||
|
||||
### QA scripts
|
||||
|
||||
```bash
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh Tolaria
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
|
||||
```
|
||||
|
||||
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Contributing to Tolaria
|
||||
|
||||
Thanks for being here! Tolaria is still early, and every bug report, idea, and contribution genuinely helps shape the app.
|
||||
|
||||
## 🗳️ Where to share what
|
||||
|
||||
To keep things clean:
|
||||
|
||||
- 🐛 Bugs → GitHub Issues
|
||||
- 💡 Feature requests / ideas → Canny • <https://tolaria.canny.io/>
|
||||
|
||||
If you have a feature idea, please check Canny first and upvote it if it already exists.
|
||||
|
||||
## 📥 Pull requests are welcome
|
||||
|
||||
PRs are very welcome.
|
||||
|
||||
A few things to keep in mind before opening one:
|
||||
|
||||
- Bug fixes are always great
|
||||
- Small improvements are great too
|
||||
- For bigger features, please check Canny first before building
|
||||
- Try to avoid things that are already marked **in progress**
|
||||
- Requests marked **planned** are usually great contribution targets
|
||||
- Keep PRs small, focused, and easy to review
|
||||
- Include a short explanation of the problem and your solution
|
||||
- Follow the dev process described in Tolaria’s `AGENTS.md` (tests, code health, etc.)
|
||||
- Avoid bundling unrelated refactors into the same PR
|
||||
|
||||
If you want to contribute a feature, the best place to start is here: <https://tolaria.canny.io/>
|
||||
|
||||
## 📋 What makes a good bug report
|
||||
|
||||
If you open a bug report on GitHub, it really helps to include:
|
||||
|
||||
- your Tolaria version
|
||||
- your OS version
|
||||
- steps to reproduce
|
||||
- what you expected to happen
|
||||
- what actually happened
|
||||
- screenshots or screen recordings if useful
|
||||
|
||||
The clearer the report, the easier it is for us to reproduce and fix it.
|
||||
|
||||
## 🙏 Thank you
|
||||
|
||||
Tolaria is getting better because people care enough to try it, report what’s broken, suggest what’s missing, and contribute improvements.
|
||||
|
||||
That means a lot. Thanks for helping build it.
|
||||
49
README.md
49
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
# 💧 Tolaria
|
||||
|
||||
Tolaria is a desktop app for Mac for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
||||
Tolaria is a desktop app for macOS, Windows, and Linux for managing **markdown knowledge bases**. People use it for a variety of use cases:
|
||||
|
||||
* Operate second brains and personal knowledge
|
||||
* Organize company docs as context for AI
|
||||
@@ -27,13 +27,25 @@ You can find some Loom walkthroughs below — they are short and to the point:
|
||||
- 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others.
|
||||
- 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria.
|
||||
- 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes.
|
||||
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code and Codex CLI (for now), but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
|
||||
- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Gemini CLI setup paths, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out.
|
||||
- ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this.
|
||||
- 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem.
|
||||
|
||||
## Getting started
|
||||
## Installation
|
||||
|
||||
Download the [latest release here](https://github.com/refactoringhq/tolaria/releases/latest/download/Tolaria.app.tar.gz).
|
||||
### Homebrew
|
||||
|
||||
Install via Homebrew on macOS:
|
||||
|
||||
```batch
|
||||
brew install --cask tolaria
|
||||
```
|
||||
|
||||
### Download from releases
|
||||
|
||||
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
|
||||
|
||||
## Getting started
|
||||
|
||||
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
|
||||
|
||||
@@ -46,7 +58,30 @@ Tolaria is open source and built with Tauri, React, and TypeScript. If you want
|
||||
- Node.js 20+
|
||||
- pnpm 8+
|
||||
- Rust stable
|
||||
- macOS for development
|
||||
- macOS or Linux for development
|
||||
|
||||
#### Linux system dependencies
|
||||
|
||||
Tauri 2 on Linux requires WebKit2GTK 4.1 and GTK 3:
|
||||
|
||||
- Arch / Manjaro:
|
||||
```bash
|
||||
sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \
|
||||
appmenu-gtk-module libappindicator-gtk3 librsvg
|
||||
```
|
||||
- Debian / Ubuntu (22.04+):
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
libsoup-3.0-dev patchelf
|
||||
```
|
||||
- Fedora 38+:
|
||||
```bash
|
||||
sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \
|
||||
libappindicator-gtk3-devel librsvg2-devel
|
||||
```
|
||||
|
||||
The bundled MCP server still spawns the system `node` binary at runtime on Linux, so install Node from your distro package manager if you want the external AI tooling flow.
|
||||
|
||||
### Quick start
|
||||
|
||||
@@ -68,6 +103,10 @@ pnpm tauri dev
|
||||
- 🚀 [GETTING-STARTED.md](docs/GETTING-STARTED.md) — How to navigate the codebase
|
||||
- 📚 [ADRs](docs/adr) — Architecture Decision Records
|
||||
|
||||
## Security
|
||||
|
||||
If you believe you have found a security issue, please report it privately as described in [SECURITY.md](./SECURITY.md).
|
||||
|
||||
## License
|
||||
|
||||
Tolaria is licensed under AGPL-3.0-or-later. The Tolaria name and logo remain covered by the project’s trademark policy.
|
||||
|
||||
55
SECURITY.md
Normal file
55
SECURITY.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Security Policy
|
||||
|
||||
Thanks for helping keep Tolaria safe.
|
||||
|
||||
If you believe you have found a security vulnerability, **please do not open a public GitHub issue**. Report it privately instead.
|
||||
|
||||
## Supported versions
|
||||
|
||||
We currently support security fixes for:
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| Latest stable release | ✅ |
|
||||
| `main` branch | Best effort |
|
||||
| Older releases / prereleases | ❌ |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please email **luca@refactoring.club** with the subject line **`[Tolaria Security]`**.
|
||||
|
||||
Include as much of the following as you can:
|
||||
|
||||
- a short description of the issue
|
||||
- reproduction steps or a proof of concept
|
||||
- affected version / commit, if known
|
||||
- impact assessment
|
||||
- any suggested mitigation
|
||||
|
||||
If the issue involves sensitive user data, credentials, or a working exploit, keep the report private and do not post details publicly.
|
||||
|
||||
## What to expect
|
||||
|
||||
We will try to:
|
||||
|
||||
- acknowledge receipt within a few business days
|
||||
- reproduce and assess the report
|
||||
- work on a fix or mitigation if the issue is valid
|
||||
- coordinate public disclosure after users have had a reasonable chance to update
|
||||
|
||||
## Disclosure guidelines
|
||||
|
||||
Please give us a reasonable amount of time to investigate and ship a fix before publishing details.
|
||||
|
||||
We appreciate responsible disclosure and good-faith research.
|
||||
|
||||
## Out of scope
|
||||
|
||||
The following are generally out of scope unless they demonstrate a real security impact:
|
||||
|
||||
- missing best-practice headers or hardening with no practical exploit
|
||||
- self-XSS or editor behavior that requires unrealistic user actions
|
||||
- reports that only affect unsupported old builds
|
||||
- purely theoretical issues with no plausible attack path
|
||||
|
||||
If you are unsure whether something qualifies, please still report it privately.
|
||||
@@ -49,7 +49,13 @@
|
||||
"laputa-qa-reference.md",
|
||||
"attachments/laputa-reference.png"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rtl-mixed-direction",
|
||||
"reason": "Arabic and mixed English/Arabic paragraphs keep rich editor and raw editor BiDi QA anchored to the fixture.",
|
||||
"files": [
|
||||
"rtl-mixed-direction-qa.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal file
17
demo-vault-v2/rtl-mixed-direction-qa.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: Note
|
||||
topics:
|
||||
- "[[topic-writing]]"
|
||||
---
|
||||
|
||||
# RTL Mixed Direction QA
|
||||
|
||||
مرحبا بالعالم. هذه فقرة عربية لاختبار اتجاه النص من اليمين إلى اليسار داخل محرر تولاريا.
|
||||
|
||||
English text should keep reading left to right when it appears next to Arabic content.
|
||||
|
||||
English then مرحبا بالعالم keeps both scripts readable on one line.
|
||||
|
||||
مرحبا بالعالم then English keeps the Arabic run anchored correctly while preserving the English words.
|
||||
|
||||
Use this note when checking rich editor and raw Markdown editor behavior for automatic LTR/RTL direction.
|
||||
@@ -47,6 +47,7 @@ _icon: shapes # icon assigned to a type
|
||||
_color: blue # color assigned to a type
|
||||
_order: 10 # sort order in the sidebar
|
||||
_sidebar_label: Projects # override label in sidebar
|
||||
_width: wide # rich-editor width override for this note
|
||||
```
|
||||
|
||||
**This convention is universal** — apply it to all future system-level frontmatter fields. When a new feature needs to store configuration in a note's frontmatter (especially in Type notes), use `_field_name` to keep it hidden from normal user-facing surfaces while still stored on-disk as plain text.
|
||||
@@ -57,6 +58,19 @@ The frontmatter parser (Rust: `vault/mod.rs`, TS: `utils/frontmatter.ts`) must f
|
||||
|
||||
All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth.
|
||||
|
||||
### Vault Git Capability
|
||||
|
||||
Git is a per-vault capability, not a prerequisite for the document model. A vault can be:
|
||||
|
||||
| State | Meaning | UI behavior |
|
||||
|---|---|---|
|
||||
| Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state |
|
||||
| Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` |
|
||||
|
||||
Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`.
|
||||
|
||||
Git initialization is intentionally scoped to dedicated vault folders. When the current non-git folder looks like a broad personal root such as Documents, Desktop, or Downloads and does not already carry Tolaria-managed vault markers, `init_git_repo` refuses to run Git and asks the user to select or create a dedicated subfolder instead.
|
||||
|
||||
### VaultEntry
|
||||
|
||||
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`).
|
||||
@@ -74,6 +88,7 @@ classDiagram
|
||||
+Record~string,string[]~ relationships
|
||||
+String[] outgoingLinks
|
||||
+String? status
|
||||
+String? noteWidth
|
||||
+Number? modifiedAt
|
||||
+Number? createdAt
|
||||
+Number wordCount
|
||||
@@ -122,6 +137,7 @@ interface VaultEntry {
|
||||
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
|
||||
outgoingLinks: string[] // All [[wikilinks]] found in note body
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
noteWidth?: 'normal' | 'wide' | null // Rich-editor width mode from `_width`
|
||||
modifiedAt: number | null // Unix timestamp (seconds)
|
||||
// Note: owner and cadence are now in the generic `properties` map
|
||||
createdAt: number | null // Unix timestamp (seconds)
|
||||
@@ -132,9 +148,32 @@ interface VaultEntry {
|
||||
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
|
||||
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
|
||||
}
|
||||
```
|
||||
|
||||
### File kinds and binary previews
|
||||
|
||||
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
|
||||
|
||||
| `fileKind` | Source files | UI behavior |
|
||||
|---|---|---|
|
||||
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync |
|
||||
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
|
||||
| `binary` | Images, audio, video, PDFs, archives, other non-text files | Stays a normal vault file; previewable media and PDFs open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
|
||||
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. 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.
|
||||
|
||||
### 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.
|
||||
|
||||
`useEditorTabSwap` may reuse BlockNote blocks that were already opened successfully or warmed from prefetched raw content, keyed by vault, path, and exact source content. Background warming is limited to likely next large Markdown notes and defers while the editor is unmounted, raw mode is active, or recent typing/navigation is still inside the foreground idle window. Every async editor swap carries a generation and source-content token so stale conversion results cannot overwrite newer file content or dirty editor state.
|
||||
|
||||
### Table of Contents Outline
|
||||
|
||||
The editor Table of Contents is derived from the live BlockNote document, not from saved Markdown text. `src/utils/tableOfContents.ts` reads structural `heading` blocks with stable ids and levels, extracts inline text from nested BlockNote content, and nests headings by level while preserving document order. `TableOfContentsPanel` receives a document revision from `Editor`, so rich-editor edits refresh the outline immediately without waiting for autosave or a vault reload. Selecting a heading focuses BlockNote and moves the cursor to that block id, while nested headings can be collapsed independently in panel-local UI state.
|
||||
|
||||
### Entity Types (isA / type)
|
||||
|
||||
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
|
||||
@@ -149,20 +188,23 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
|
||||
├── some-topic.md ← type: Topic
|
||||
├── AGENTS.md ← canonical Tolaria AI guidance
|
||||
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
|
||||
├── GEMINI.md ← optional Gemini CLI shim pointing at AGENTS.md
|
||||
├── project.md ← type: Type (definition document)
|
||||
├── person.md ← type: Type (definition document)
|
||||
├── ...
|
||||
└── type/ ← type definition documents
|
||||
```
|
||||
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
|
||||
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. Moving a note into a user folder is a separate filesystem concern: the folder path changes, but the note keeps the same filename and `type:` value. Legacy `type/` and `types/` folders are still scanned like other non-hidden vault folders, so existing type documents in those folders continue to work, but new type documents created by Tolaria are written at the vault root. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
|
||||
|
||||
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
|
||||
|
||||
### Types as Files
|
||||
|
||||
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
|
||||
Each entity type can have a corresponding **type document**: any markdown note with `type: Type` in its frontmatter. Tolaria creates new type documents at the vault root (e.g., `project.md`, `person.md`) and still reads existing type documents from subfolders. Type documents:
|
||||
|
||||
- Have `type: Type` in their frontmatter (`Is A: Type` also accepted as legacy alias)
|
||||
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
|
||||
- Define instance schema/defaults through ordinary custom frontmatter properties and relationship fields
|
||||
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
|
||||
- Serve as the "definition" for their type category
|
||||
|
||||
@@ -171,7 +213,7 @@ Each entity type can have a corresponding **type document** in the `type/` folde
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `icon` | string | Type icon as a Phosphor name (kebab-case, e.g., "cooking-pot") |
|
||||
| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
|
||||
| `color` | string | Accent palette key (`red`, `purple`, `blue`, `green`, `yellow`, `orange`, `teal`, `pink`, `gray`) or a valid CSS color value such as `cyan`, `#22d3ee`, or `rgb(34, 211, 238)` |
|
||||
| `order` | number | Sidebar display order (lower = higher priority) |
|
||||
| `sidebar_label` | string | Custom label overriding auto-pluralization |
|
||||
| `template` | string | Markdown template for new notes of this type |
|
||||
@@ -179,7 +221,9 @@ Each entity type can have a corresponding **type document** in the `type/` folde
|
||||
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
|
||||
| `visible` | bool | Whether type appears in sidebar (default: true) |
|
||||
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[project]]`. This makes the type navigable from the Inspector panel while keeping location as an implementation detail.
|
||||
|
||||
**Instance schema/defaults**: Custom scalar properties and relationship fields on a type document define the expected shape for notes of that type. Existing instances do not get mutated when a type changes; the Inspector enriches their real frontmatter with gray placeholders for missing type-defined properties/relationships. Valued type fields are copied into frontmatter only when Tolaria creates a new instance of that type. Blank type fields stay as placeholders.
|
||||
|
||||
**UI behavior**:
|
||||
- Clicking a section group header pins the type document at the top of the NoteList if it exists
|
||||
@@ -239,7 +283,11 @@ Tolaria separates **display title** from the file identifier:
|
||||
|
||||
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
|
||||
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
|
||||
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions update the filename and wikilinks across the vault. The editor body remains the title editing surface.
|
||||
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions stage crash-safe note renames through a hidden `.tolaria-rename-txn/` transaction directory, recover unfinished renames on the next vault scan, update wikilinks across the vault, and surface any failed backlink rewrites instead of silently reporting partial success. The editor body remains the title editing surface.
|
||||
- **Unicode-aware note stems** (`src/utils/noteSlug.ts`, `vault/rename.rs`): frontend and backend slugging preserve Unicode letters/digits in note filenames, untitled-rename detection, and fallback wikilink targets while still collapsing symbol-only titles to `untitled`.
|
||||
- **Path identity rules** (`src/utils/notePathIdentity.ts`, `vault/path_identity.rs`): note creation, tab selection, rename bookkeeping, pull refresh, git history, and vault cache updates normalize path separators and macOS `/private/tmp` aliases through one owner. Case folding is reserved for collision/deduplication checks; active-note identity remains case-sensitive.
|
||||
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
|
||||
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while transient access-denied writes are retried briefly before surfacing failure. The editor keeps the unsaved buffer intact for another attempt.
|
||||
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
|
||||
|
||||
### Title Surface (UI)
|
||||
@@ -261,18 +309,26 @@ type SidebarFilter = 'all' | 'archived' | 'changes' | 'pulse'
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
|
||||
| { kind: 'folder'; path: string }
|
||||
| { kind: 'folder'; path: string; rootPath?: string }
|
||||
| { kind: 'entity'; entry: VaultEntry } // Neighborhood source note
|
||||
| { kind: 'view'; filename: string }
|
||||
```
|
||||
|
||||
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
|
||||
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated.
|
||||
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
|
||||
- `src/components/sidebar/sidebarHooks.ts` owns the shared sidebar interaction primitives for menu positioning/dismissal and inline rename input behavior. Folder, Type, and saved View rows keep their domain-specific actions local, but use those primitives so right-click menus and rename fields have the same outside-click, Escape, focus, blur, and submit semantics.
|
||||
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
|
||||
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
|
||||
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
|
||||
- Folder deletion clears pending rename state, confirms destructive intent, drops affected folder-scoped tabs, reloads vault data, and resets folder selection if the deleted subtree owned the current selection.
|
||||
|
||||
### Saved Views
|
||||
|
||||
Saved Views live as YAML files under `views/`. Their definition includes user-visible fields (`name`, `icon`, `color`), note-list preferences (`sort`, `listPropertiesDisplay`), filters, and an optional top-level `order` number. The `order` value is stored directly in the YAML document, not in Markdown frontmatter, and lower values render earlier in every saved-View list. Views without an explicit order sort after ordered views by filename for stable fallback behavior.
|
||||
|
||||
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
|
||||
|
||||
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
|
||||
@@ -286,6 +342,12 @@ type SidebarSelection =
|
||||
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
|
||||
- Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood.
|
||||
|
||||
## Command Surface
|
||||
|
||||
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups.
|
||||
|
||||
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
|
||||
|
||||
## File System Integration
|
||||
|
||||
### Vault Scanning (Rust)
|
||||
@@ -293,22 +355,31 @@ type SidebarSelection =
|
||||
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
|
||||
|
||||
1. Validates the path exists and is a directory
|
||||
2. Scans root-level `.md` files (non-recursive)
|
||||
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
|
||||
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
|
||||
5. For each `.md` file, calls `parse_md_file()`:
|
||||
2. Recursively scans non-hidden files while skipping hidden directories such as `.git/`
|
||||
3. For each `.md` file, calls `parse_md_file()`:
|
||||
- Reads content with `fs::read_to_string()`
|
||||
- Parses frontmatter with `gray_matter::Matter::<YAML>`
|
||||
- Extracts title from first `#` heading
|
||||
- Reads entity type from `type:` frontmatter field (`Is A:` accepted as legacy alias); type is never inferred from folder
|
||||
- Parses dates as ISO 8601 to Unix timestamps
|
||||
- Extracts relationships, outgoing links, custom properties, word count, snippet
|
||||
6. Sorts by `modified_at` descending
|
||||
7. Skips unparseable files with a warning log
|
||||
4. For recognized non-markdown text and binary files, emits a minimal `VaultEntry` with `fileKind`
|
||||
5. Sorts by `modified_at` descending
|
||||
6. Skips unparseable files with a warning log
|
||||
|
||||
All Notes starts from Markdown notes and excludes Markdown files under `attachments/`. `src/utils/allNotesFileVisibility.ts` resolves the installation-local PDF, image, and unsupported-file toggles from app settings; `noteListHelpers` applies that policy only to All Notes filtering and counts. Folder/root browsing continues to show files from the selected folder independently of those All Notes toggles.
|
||||
|
||||
The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders under the synthetic vault-root row.
|
||||
|
||||
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, drains stdout while stdin is still being written, and short-circuits root `.gitignore` detection before walking for nested ignore files, so large ignored folder sets cannot deadlock the native UI while preserving Git semantics as closely as the app can reasonably support.
|
||||
|
||||
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
|
||||
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, and folder mutations cannot step outside the active vault.
|
||||
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
|
||||
|
||||
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
|
||||
|
||||
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.
|
||||
|
||||
### Vault Caching
|
||||
|
||||
@@ -319,7 +390,7 @@ Command-layer path access is fenced to the active vault before file operations r
|
||||
3. If cache is valid and same commit → only re-parse uncommitted changed files
|
||||
4. If different commit → use `git diff` to find changed files → selective re-parse
|
||||
5. If no cache → full scan
|
||||
6. Writes updated cache atomically (write to `.tmp`, then rename)
|
||||
6. Replaces the cache with a temp-file write + rename only if a short-lived writer lock and cache fingerprint check show another scan has not already refreshed it
|
||||
7. On first run, migrates any legacy `.laputa-cache.json` from inside the vault
|
||||
|
||||
### Frontmatter Manipulation (Rust)
|
||||
@@ -342,7 +413,7 @@ The `with_frontmatter()` helper wraps this in a read-transform-write cycle on th
|
||||
|
||||
## Git Integration
|
||||
|
||||
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2).
|
||||
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2). Path-producing commands use `core.quotePath=false` so Unicode note filenames stay as UTF-8 paths across status, history, cache invalidation, and rename detection.
|
||||
|
||||
### Data Types
|
||||
|
||||
@@ -393,7 +464,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 |
|
||||
| `commit.rs` | Commit | `git add -A && git commit -m "..."` |
|
||||
| `commit.rs` | Commit | `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 |
|
||||
| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual |
|
||||
@@ -405,13 +476,17 @@ interface PulseCommit {
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Awaits the post-pull vault refresh so toasts land after note-list state is fresh
|
||||
- Reopens the clean active tab from disk after a successful pull update so the editor and note list stay aligned
|
||||
- Reopens the clean active tab from disk only when the pull changed that active note, so unrelated updates do not remount the editor
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
|
||||
- Handles push rejection (divergence) → sets `pull_required` status
|
||||
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
|
||||
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
|
||||
|
||||
### 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.
|
||||
|
||||
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
|
||||
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
|
||||
- Converts `hasRemote: false` into a local-only commit path
|
||||
@@ -465,41 +540,81 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
|
||||
|
||||
- The schema overrides BlockNote's default `codeBlock` spec with `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })` from `@blocknote/code-block`.
|
||||
- Fenced code blocks now use BlockNote's supported Shiki-backed highlighter path, which renders `.shiki` token spans directly inside the editor DOM.
|
||||
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript while still supporting the packaged language aliases such as `ts` → `typescript`.
|
||||
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep BlockNote's dark shell instead of inheriting the muted inline surface.
|
||||
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript at creation time. Parsed unlabeled code blocks then run through Tolaria's lightweight language inference, while explicit fence languages and user dropdown choices still win.
|
||||
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep the dedicated code-block shell instead of inheriting the muted inline surface.
|
||||
|
||||
### Markdown Math
|
||||
|
||||
Defined in `src/utils/mathMarkdown.ts`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- `$...$` 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.
|
||||
- `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.
|
||||
|
||||
### Mermaid Diagrams
|
||||
|
||||
Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/mermaidMarkdown.ts`, `src/components/MermaidDiagram.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- Fenced `mermaid` blocks become `mermaidBlock` schema nodes before BlockNote sees the Markdown body.
|
||||
- Each `mermaidBlock` stores the original fenced Markdown plus the diagram body, so raw-mode entry and saves can restore the canonical source instead of serializing generated SVG.
|
||||
- The rich editor renders diagrams with the `mermaid` package and uses the original source as an inline fallback when rendering fails.
|
||||
- `serializeDurableEditorBlocks()` wraps the math-aware serializer so math, wikilinks, Mermaid diagrams, and whiteboards share the same Markdown-first save path.
|
||||
- The `/mermaid` slash command inserts a placeholder rectangle diagram using the same schema-backed Markdown storage path, avoiding an invalid empty diagram state.
|
||||
|
||||
### Tldraw Whiteboards
|
||||
|
||||
Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdown.ts`, `src/utils/tldrawMarkdown.ts`, `src/components/TldrawWhiteboard.tsx`, `src/components/editorSchema.tsx`, and styled in `src/components/EditorTheme.css`:
|
||||
|
||||
- Fenced `tldraw` blocks become `tldrawBlock` schema nodes before BlockNote sees the Markdown body.
|
||||
- Each `tldrawBlock` stores a stable `boardId` plus the tldraw document snapshot JSON. Session state such as camera, selected tool, and current selection is not persisted into the note.
|
||||
- The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file.
|
||||
- Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner.
|
||||
- The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts.
|
||||
|
||||
### Formatting Surface Policy
|
||||
|
||||
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
|
||||
|
||||
- `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 narrow whitespace mouse-selection bridge around BlockNote: 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.
|
||||
- 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.
|
||||
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
|
||||
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.
|
||||
- `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.
|
||||
- `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.
|
||||
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Checklist checkbox handlers also re-resolve the live block before updating `checked`, making delayed clicks after note reloads a no-op instead of a stale block mutation. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
|
||||
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
|
||||
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.
|
||||
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.
|
||||
- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor.
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["📄 Raw markdown\n(from disk)"] --> B["splitFrontmatter()\n→ yaml + body"]
|
||||
B --> C["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
C --> D["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
D --> E["injectWikilinks(blocks)\n‹token› → WikiLink node"]
|
||||
E --> F["editor.replaceBlocks()\n→ rendered editor"]
|
||||
B --> C["preProcessDurableEditorMarkdown(body)\nmermaid/tldraw fences → tokens"]
|
||||
C --> D["preProcessWikilinks(body)\n[[target]] → ‹token›"]
|
||||
D --> E["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
|
||||
E --> F["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
|
||||
F --> G["injectWikilinks + injectMathInBlocks + injectDurableEditorMarkdownBlocks\n tokens → schema nodes"]
|
||||
G --> H["editor.replaceBlocks()\n→ rendered editor"]
|
||||
|
||||
style A fill:#f8f9fa,stroke:#6c757d,color:#000
|
||||
style F fill:#d4edda,stroke:#28a745,color:#000
|
||||
style H fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
> Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
|
||||
> Wikilink placeholder tokens use `\u2039` and `\u203A`; math, Mermaid, and tldraw placeholder tokens use ASCII sentinels with URI-encoded payloads.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["✏️ BlockNote blocks\n(editor state)"] --> B["blocksToMarkdownLossy()"]
|
||||
B --> C["postProcessWikilinks()\nWikiLink node → [[target]]"]
|
||||
B --> C["restoreWikilinks + serializeDurableEditorBlocks()\nschema nodes → Markdown source"]
|
||||
C --> D["prepend frontmatter yaml"]
|
||||
D --> E["invoke('save_note_content')\n→ disk write"]
|
||||
|
||||
@@ -507,6 +622,10 @@ flowchart LR
|
||||
style E fill:#d4edda,stroke:#28a745,color:#000
|
||||
```
|
||||
|
||||
Rich-editor change events are coalesced before this serialization runs. `useEditorTabSwap` keeps the latest BlockNote state in the editor, schedules one Markdown serialization for a short idle window, and exposes an explicit flush hook for save, note switch, raw-mode entry, and destructive note actions. This keeps long notes from paying full-document Markdown serialization on every keystroke while preserving the disk-first save path.
|
||||
|
||||
Autosave then waits for a 1.5s idle window before invoking `save_note_content`. If an older save resolves after the user has already typed newer content, the older save is treated as stale and cannot clear the newer pending buffer or repaint tab state over it; the latest pending content remains scheduled for its own save.
|
||||
|
||||
### Wikilink Navigation
|
||||
|
||||
Two navigation mechanisms:
|
||||
@@ -521,12 +640,43 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
|
||||
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
|
||||
While the user types, `useEditorSaveWithLinks` derives a transient `VaultEntry` patch from parseable frontmatter so the Inspector, relationship chips, and note-list-visible metadata stay in sync with the raw editor before the next vault reload. Temporarily invalid or half-typed frontmatter is ignored until it becomes parseable again, which avoids clobbering the last known good derived state.
|
||||
|
||||
Current-note find/replace is intentionally backed by raw CodeMirror mode. `Cmd+F`, "Find in Note", and "Replace in Note" switch the active Markdown/text note to raw mode, show the compact find bar above CodeMirror, and operate on the current note only. Plain text matching is case-insensitive by default, `Aa` toggles case sensitivity, `.*` toggles JavaScript-regex matching, and regex replacement supports capture groups through JavaScript replacement syntax.
|
||||
|
||||
### Rich Editor Width Modes
|
||||
|
||||
Rich Markdown editing supports `normal` and `wide` note widths. The effective mode is resolved in `App.tsx` from, in order, the current session's transient note-width cache, `VaultEntry.noteWidth` parsed from `_width`, and the installation-local `settings.note_width_mode` default. The breadcrumb toggle calls the same setter exposed through the command palette.
|
||||
|
||||
Per-note width is persisted as hidden `_width` frontmatter only when the note already has a valid or empty frontmatter block. Notes without frontmatter use the transient cache for the current session, so toggling width never creates frontmatter solely to store UI state. The width class is applied around `SingleEditorView` only; raw CodeMirror mode stays outside `.editor-content-wrapper` and remains full-width.
|
||||
|
||||
### Arrow Ligature Normalization
|
||||
|
||||
Typed ASCII arrow sequences are normalized consistently in both editor modes:
|
||||
|
||||
- Rich editor input mounts `createArrowLigaturesExtension()` (`src/components/arrowLigaturesExtension.ts`) into BlockNote and intercepts typed `beforeinput` events before ProseMirror commits the character.
|
||||
- Raw editor input uses the CodeMirror `inputHandler` path in `useCodeMirror` so the same ligature rules apply while editing markdown source directly.
|
||||
- Both paths delegate to the shared `resolveArrowLigatureInput()` helper in `src/utils/arrowLigatures.ts`, which prioritizes `<->` over partial matches, keeps paste literal, and lets escaped forms such as `\\->` and `\\<->` remain ASCII.
|
||||
- The rich-editor extension treats stale, disconnected, or mid-reload ProseMirror views as a no-op. It never blocks the native input path unless it has already built and dispatched a valid ligature transaction.
|
||||
|
||||
## Styling
|
||||
|
||||
The app uses a single light theme — the vault-based theming system was removed (see [ADR-0013](adr/0013-remove-theming-system.md)). Styling is defined in two layers:
|
||||
The app uses internal light and dark themes owned by Tolaria, with System as an installation-local preference that follows the OS appearance (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md) and [ADR-0112](adr/0112-system-theme-mode.md)). The previous vault-authored theming system remains removed.
|
||||
|
||||
1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4
|
||||
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states via `:root` / `[data-theme]`, bridged to Tailwind v4
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
|
||||
3. **Runtime theme bridge**: Resolves the selected preference to `light` / `dark`, applies `data-theme` and `.dark` for shadcn/ui, and subscribes to `prefers-color-scheme` while System is selected
|
||||
4. **Theme mode commands**: Command-palette actions for Light, Dark, and System call the same `saveSettings` path as the Settings panel and persist only `settings.theme_mode`
|
||||
|
||||
## Localization
|
||||
|
||||
App UI strings are resolved through `src/lib/i18n.ts`, with flat JSON catalogs in `src/lib/locales/*.json` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)):
|
||||
|
||||
- `AppLocale`: canonical locale tags such as `'en'`, `'zh-CN'`, `'fr-FR'`, `'es-419'`
|
||||
- `UiLanguagePreference`: `'system' | AppLocale`; persisted settings serialize `system` as `null`
|
||||
- `resolveEffectiveLocale()`: maps an explicit preference or system/browser language list to the effective supported locale, including legacy aliases
|
||||
- `translate()` / `createTranslator()`: resolve keys with English fallback and simple `{name}` interpolation
|
||||
- `scripts/validate-locales.mjs`: asserts every checked-in locale catalog matches the English keyset and stays flat-string-only
|
||||
|
||||
`App.tsx` owns the effective locale and passes it to localized app chrome through props. Settings and command-palette language commands call back into `saveSettings`, so UI language changes update the current session without touching vault content or reopening the vault.
|
||||
|
||||
## Inspector Abstraction
|
||||
|
||||
@@ -535,10 +685,12 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
|
||||
- **Present empty properties**: A top-level frontmatter key with a blank scalar value (for example `start date:`) is treated as present and renders as an editable empty row. Only absent keys are omitted.
|
||||
- **Type-derived placeholders**: For typed instances, missing custom properties declared on the type document render as gray editable placeholders. Editing one writes the value to the instance frontmatter; merely displaying it does not backfill the note.
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged.
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged. For typed instances, missing relationship fields declared on the type document render as gray editable placeholders without copying any default relationship targets into existing notes.
|
||||
|
||||
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
||||
|
||||
@@ -548,7 +700,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
### Search
|
||||
|
||||
Keyword-based search scans all vault `.md` files using `walkdir`:
|
||||
Keyword-based search scans all vault `.md` files using `walkdir` and applies the same Gitignored-content visibility filter as vault loading:
|
||||
|
||||
```typescript
|
||||
interface SearchResult {
|
||||
@@ -582,16 +734,20 @@ No indexing step required — search runs directly against the filesystem.
|
||||
|
||||
Per-vault settings stored locally and scoped by vault path:
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
|
||||
- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`)
|
||||
- Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
Installation-local layout state that should not sync through a vault stays in localStorage. `useLayoutPanels` stores the clamped sidebar, note-list, and inspector widths under `tolaria:layout-panels` so pane sizing survives app relaunches on the same machine.
|
||||
|
||||
### AI Guidance Files
|
||||
|
||||
Tolaria tracks managed vault-level AI guidance separately from normal note content:
|
||||
- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents
|
||||
- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`
|
||||
- `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; user-authored custom `AGENTS.md` / `CLAUDE.md` files are surfaced as custom and left untouched
|
||||
- `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
|
||||
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
|
||||
|
||||
### Getting Started / Onboarding
|
||||
@@ -610,15 +766,18 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
|
||||
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
|
||||
- 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 treats 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
|
||||
- Persists dismissal locally once the user continues
|
||||
|
||||
### Remote Git Operations
|
||||
|
||||
Tolaria delegates remote auth to the user's system git setup:
|
||||
- `CloneVaultModal` captures a remote URL and local destination
|
||||
- `clone_repo` shells out to system git for clone operations
|
||||
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
|
||||
- On Linux AppImage launches, every system-git command removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning `git`, so helpers like `git-remote-https` bind against the host git/library stack instead of Tolaria's bundled WebKit/AppImage libraries
|
||||
- On Linux AppImage Wayland launches, startup environment safeguards also set `GTK_IM_MODULE=fcitx` when common input-method variables already indicate fcitx and the user has not explicitly chosen a GTK IM module, allowing WebKitGTK editor input to reach fcitx5 on compositors such as niri without overriding deliberate `GTK_IM_MODULE` choices.
|
||||
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
|
||||
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors
|
||||
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors, and clone commands fail fast when git wants interactive terminal input
|
||||
- No provider-specific token or username is stored in app settings
|
||||
|
||||
## Settings
|
||||
@@ -636,11 +795,21 @@ interface Settings {
|
||||
analytics_enabled: boolean | null
|
||||
anonymous_id: string | null
|
||||
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
|
||||
default_ai_agent: 'claude_code' | 'codex' | null
|
||||
theme_mode: 'light' | 'dark' | 'system' | null
|
||||
ui_language: AppLocale | null
|
||||
note_width_mode: 'normal' | 'wide' | null
|
||||
sidebar_type_pluralization_enabled: boolean | null // null = default true
|
||||
default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null
|
||||
default_ai_target: string | null // "agent:codex" or "model:<provider>/<model>"
|
||||
ai_model_providers: AiModelProvider[] | null
|
||||
hide_gitignored_files: boolean | null // null = default true
|
||||
all_notes_show_pdfs: boolean | null // null = default false
|
||||
all_notes_show_images: boolean | null // null = default false
|
||||
all_notes_show_unsupported: boolean | null // null = default false
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default. 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. `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. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation.
|
||||
|
||||
## Telemetry
|
||||
|
||||
@@ -652,8 +821,16 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
- **`useTelemetry(settings, loaded)`** — Reactively initializes/tears down Sentry and PostHog based on settings. Called once in `App`.
|
||||
|
||||
### Libraries
|
||||
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` / `VITE_POSTHOG_KEY` env vars.
|
||||
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes. `reinit_sentry()` for runtime toggle.
|
||||
- **`src/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/key from `VITE_SENTRY_DSN` and `VITE_POSTHOG_KEY`; `VITE_SENTRY_RELEASE` is treated as the build version and only becomes Sentry's `release` for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds tag `tolaria.build_version` and `tolaria.release_kind` without creating normal Sentry Releases entries.
|
||||
- **`src/main.tsx`** — React root error callbacks (`onCaughtError`, `onUncaughtError`, `onRecoverableError`) forward component-stack context to `Sentry.reactErrorHandler()` for debuggable production React errors.
|
||||
- **`src-tauri/src/telemetry.rs`** — Rust-side Sentry init with `beforeSend` path scrubber. `init_sentry_from_settings()` reads settings and conditionally initializes; stable calendar `CARGO_PKG_VERSION` values become Sentry releases, while alpha/prerelease/internal versions are kept as diagnostic tags only. `reinit_sentry()` for runtime toggle.
|
||||
|
||||
### Product Events
|
||||
- **File previews** — `file_preview_opened`, `file_preview_action`, and `file_preview_failed` report only preview/action categories such as `image`, `pdf`, `unsupported`, `open_external`, `copy_path`, and `reveal`.
|
||||
- **Inline image lightbox** — `inline_image_lightbox_opened` records that a rich-editor inline image was opened from double-click, without sending note paths, image URLs, alt text, or file names.
|
||||
- **Code block copy** — `code_block_copied` records that the rich-editor code-block copy action was used, without sending note paths, languages, or code content.
|
||||
- **AI agent sessions** — `ai_agent_message_sent`, `ai_agent_message_blocked`, `ai_agent_response_completed`, `ai_agent_response_failed`, and `ai_agent_permission_mode_changed` use only agent ids, permission modes, counts, and coarse status categories.
|
||||
- **All Notes visibility** — `all_notes_visibility_changed` records only the toggled category and enabled state.
|
||||
|
||||
### Tauri Commands
|
||||
- **`reinit_telemetry`** — Re-reads settings and toggles Rust Sentry on/off. Called from frontend when user changes crash reporting setting.
|
||||
@@ -663,7 +840,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
## Updates & Feature Flags
|
||||
|
||||
### Hooks
|
||||
- **`useUpdater(releaseChannel)`** — Channel-aware updater state machine. Checks the selected feed, surfaces available/downloading/ready states, and delegates install work to Rust.
|
||||
- **`useUpdater(releaseChannel)`** — Channel-aware updater state machine. Checks the selected feed, surfaces checking/available/downloading/ready states, and delegates install work to Rust.
|
||||
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union.
|
||||
|
||||
### Frontend helpers
|
||||
@@ -679,6 +856,6 @@ Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent`
|
||||
- **`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` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
|
||||
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
|
||||
- **`.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. 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, and Linux x86_64 `.deb` / AppImage artifacts. 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.
|
||||
|
||||
@@ -24,6 +24,10 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
| Pinned properties per type | API keys (OpenAI, Google) |
|
||||
| Sidebar label overrides | Auto-sync interval |
|
||||
| Property display order | Window size / position |
|
||||
| Per-note `_width` rich-editor width override | Default rich-editor note width |
|
||||
| Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files |
|
||||
| Per-vault All Notes note-list column overrides | All Notes PDF/image/unsupported file visibility |
|
||||
| Type `_sidebar_label` overrides | Whether this installation auto-pluralizes type labels |
|
||||
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
|
||||
|
||||
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
|
||||
@@ -31,7 +35,12 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
|
||||
Examples:
|
||||
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
|
||||
- ✅ Vault: `_icon: shapes` in a Type note (icon is part of the type's identity)
|
||||
- ✅ Vault: `_width: wide` in a note that already has frontmatter (per-note reading/editing preference)
|
||||
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
|
||||
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
|
||||
- ✅ App settings: `note_width_mode: "wide"` (installation-specific default for notes without an override)
|
||||
- ✅ App settings: `sidebar_type_pluralization_enabled: false` (installation-specific sidebar label preference)
|
||||
- ✅ App settings: `all_notes_show_images: true` (installation-specific All Notes file-category visibility)
|
||||
|
||||
### No hardcoded exceptions
|
||||
|
||||
@@ -81,6 +90,23 @@ flowchart LR
|
||||
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
|
||||
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
|
||||
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
|
||||
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape. Large folder filtering runs on the blocking Tokio pool and drains `git check-ignore` output while feeding stdin so broad ignore matches cannot freeze the native UI thread.
|
||||
|
||||
#### External Change Detection
|
||||
|
||||
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.
|
||||
|
||||
#### Progressive Vault Loading
|
||||
|
||||
Vault opening is allowed to render the main app shell while the full entry scan is still in flight. `useVaultLoader` keeps `isLoading` true until entries are ready, but folders and saved views load independently so the sidebar can become useful before the note index completes. The status bar uses the vault activity badge during this initial indexing state, while command-palette and editor-shell interactions remain mounted instead of being hidden behind the full app skeleton. The full skeleton is reserved for app-level capability checks such as the initial Git-state probe.
|
||||
|
||||
Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.md](./LARGE-VAULT-LOADING-QA.md).
|
||||
|
||||
#### Note Opening Fast Path
|
||||
|
||||
Note opening uses bounded in-memory fast paths for raw content and parsed editor blocks. `useTabManagement` owns the markdown/text prefetch cache and treats every cached value as a performance hint only: identity-matched entries (`modifiedAt` + `fileSize`) can be reused immediately, while identity-missing or identity-mismatched cached text is checked with `validate_note_content`, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor.
|
||||
|
||||
The note list opportunistically preloads visible and adjacent markdown/text entries after a short delay. When a large warmed Markdown note resolves, `useEditorTabSwap` may parse it into a bounded parsed-block cache only after foreground editor work has been idle and the rich editor is mounted. Parsed blocks are keyed by vault, path, and exact source content; every async swap carries a generation/source-content token so stale conversion results cannot overwrite newer file content or dirty editor state. The editor never renders a preview surface that later morphs into BlockNote. See [ADR-0105](./adr/0105-editor-correctness-and-responsiveness-contract.md).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -90,6 +116,8 @@ flowchart LR
|
||||
| Frontend | React + TypeScript | React 19, TS 5.9 |
|
||||
| Editor | BlockNote | 0.46.2 |
|
||||
| Code block highlighting | @blocknote/code-block | 0.46.2 |
|
||||
| Diagram rendering | Mermaid | 11.14.0 |
|
||||
| Whiteboard rendering | tldraw | 4.5.10 |
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
|
||||
| UI primitives | Radix UI + shadcn/ui | - |
|
||||
@@ -97,8 +125,10 @@ flowchart LR
|
||||
| Build | Vite | 7.3.1 |
|
||||
| Backend language | Rust (edition 2021) | 1.77.2 |
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
|
||||
| Filesystem watcher | notify | 6.1 |
|
||||
| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini) | - |
|
||||
| Search | Keyword (walkdir-based file scan) | - |
|
||||
| Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync |
|
||||
| MCP | @modelcontextprotocol/sdk | 1.0 |
|
||||
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
|
||||
| Package manager | pnpm | - |
|
||||
@@ -114,7 +144,7 @@ flowchart TD
|
||||
SB["Sidebar\n(navigation + filters + types)"]
|
||||
NL["NoteList / PulseView\n(filtered list / activity)"]
|
||||
ED["Editor\n(BlockNote + diff + raw)"]
|
||||
IN["Inspector\n(metadata + relationships)"]
|
||||
IN["Right Panel\n(Inspector + TOC)"]
|
||||
AIP["AiPanel\n(selected CLI agent + tools)"]
|
||||
SP["SearchPanel\n(keyword search)"]
|
||||
ST["StatusBar\n(vault picker + sync + version)"]
|
||||
@@ -131,11 +161,11 @@ flowchart TD
|
||||
GIT["git/\n(commit, sync, clone)"]
|
||||
SETTINGS["settings.rs"]
|
||||
SEARCH["search.rs"]
|
||||
CLI["ai_agents.rs\n+ claude_cli.rs"]
|
||||
CLI["ai_agents.rs\n+ CLI adapters"]
|
||||
end
|
||||
|
||||
subgraph EXT["External Services"]
|
||||
CCLI["Claude CLI / Codex CLI\n(agent subprocesses)"]
|
||||
CCLI["Claude / Codex / OpenCode / Pi / Gemini CLI\n(agent subprocesses)"]
|
||||
MCP["MCP Server\n(ws://9710, 9711)"]
|
||||
GCLI["git CLI\n(system executable)"]
|
||||
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
|
||||
@@ -157,10 +187,10 @@ flowchart TD
|
||||
|
||||
```
|
||||
┌────────┬─────────────┬─────────────────────────┬────────────┐
|
||||
│Sidebar │ Note List │ Editor │ Inspector │
|
||||
│Sidebar │ Note List │ Editor │ Right Panel│
|
||||
│(250px) │ (300px) │ (flex-1) │ (280px) │
|
||||
│ │ OR │ │ OR │
|
||||
│ All │ Pulse View │ [Breadcrumb Bar] │ AI Chat │
|
||||
│ All │ Pulse View │ [Breadcrumb Bar] │ TOC │
|
||||
│ Changes│ │ │ OR │
|
||||
│ Pulse │ [Search] │ # My Note │ AI Agent │
|
||||
│ Inbox │ [Sort/Filt] │ │ │
|
||||
@@ -176,14 +206,21 @@ flowchart TD
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, and auto-expands ancestor folders when the current selection or rename target is nested. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
|
||||
- **Note List / Pulse View** (200-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. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
|
||||
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable 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 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. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
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`.
|
||||
|
||||
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing.
|
||||
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds the current sidebar / note-list / expanded-inspector widths on top with minimum floors, and calls the native `update_current_window_min_size` command whenever view mode, inspector visibility, or restored pane widths change. That same native command can grow the current window back out when a wider pane combination is restored, but Windows keeps grow-to-fit disabled so opening or closing the Properties panel does not unmaximize or reposition the main window. Note windows skip this path and keep their dedicated 800×700 initial sizing.
|
||||
|
||||
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.
|
||||
|
||||
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with visible updater feedback.
|
||||
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. If an AppImage Wayland session advertises fcitx through common input-method environment hints and the user has not already chosen `GTK_IM_MODULE`, Tolaria sets `GTK_IM_MODULE=fcitx` before WebKit starts so GTK/WebKit input contexts reach fcitx5 on compositors that do not reliably provide the Wayland input method frontend. The same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, the fcitx GTK fallback preserves Chinese/Japanese/Korean IME composition for affected AppImage Wayland users, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
@@ -199,8 +236,8 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
- `openNoteInNewWindow()` (`src/utils/openNoteWindow.ts`) creates a new `WebviewWindow` via the Tauri v2 JS API with URL query params (`?window=note&path=...&vault=...&title=...`)
|
||||
- `main.tsx` checks `isNoteWindow()` at boot to route between `App` (main window) and `NoteWindow` (secondary window)
|
||||
- `NoteWindow` (`src/NoteWindow.tsx`) is a minimal shell that loads vault entries, fetches note content, applies the theme, and renders a single `Editor` instance
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 500ms debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
|
||||
- Secondary windows are sized 800×700 with overlay title bar
|
||||
- Each window has its own auto-save via `useEditorSaveWithLinks` (same 1.5s low-end-safe idle debounce, same Rust `save_note_content` command), and raw-editor typing also derives frontmatter-backed `VaultEntry` state in the renderer so Inspector and note-list surfaces react immediately without waiting for a full reload
|
||||
- Secondary windows are sized 800×700; macOS keeps the overlay title bar, while Linux mounts the shared React titlebar on undecorated windows
|
||||
- Capabilities config (`src-tauri/capabilities/default.json`) grants permissions to both `main` and `note-*` window labels
|
||||
|
||||
## AI System
|
||||
@@ -209,10 +246,13 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
|
||||
|
||||
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
|
||||
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
|
||||
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json`
|
||||
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
|
||||
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts` + `aiTargets.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-target selection, and the per-vault Safe / Power User permission mode shown in the panel header for coding agents
|
||||
2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters
|
||||
3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents
|
||||
4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Codex, OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags.
|
||||
5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
|
||||
CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, nvm-managed Node installs, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path.
|
||||
|
||||
#### Agent Event Flow
|
||||
|
||||
@@ -226,12 +266,12 @@ sequenceDiagram
|
||||
|
||||
U->>FE: sendMessage(text, references)
|
||||
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
|
||||
R->>R: pick adapter for claude_code or codex
|
||||
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode})
|
||||
R->>R: pick adapter for claude_code, codex, opencode, or pi
|
||||
R->>C: spawn agent with MCP-enabled config
|
||||
|
||||
loop Normalized stream
|
||||
C-->>R: Claude NDJSON or Codex JSONL events
|
||||
C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, Pi JSON, or Gemini JSONL events
|
||||
R-->>FE: emit("ai-agent-stream", event)
|
||||
alt TextDelta
|
||||
FE->>FE: accumulate response (revealed on Done)
|
||||
@@ -253,7 +293,7 @@ sequenceDiagram
|
||||
|
||||
#### File Operation Detection
|
||||
|
||||
When the agent writes or edits vault files, `useCliAiAgent` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
|
||||
When the agent writes or edits vault files, `aiAgentFileOperations.ts` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload. Unrecognized write-like operations fall back to a full vault refresh.
|
||||
|
||||
### Context Building
|
||||
|
||||
@@ -261,23 +301,29 @@ The agent panel (`ai-context.ts`) builds a structured JSON snapshot from the act
|
||||
|
||||
```json
|
||||
{
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "content" },
|
||||
"linkedNotes": [{ "path", "title", "content" }],
|
||||
"openTabs": [{ "title", "snippet" }],
|
||||
"vaultMetadata": { "noteTypes", "stats", "filter" },
|
||||
"references": [{ "title", "path", "type" }]
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "body", "wordCount", "bodyTruncated?" },
|
||||
"openTabs": [{ "path", "title", "type", "frontmatter" }],
|
||||
"noteList": [{ "path", "title", "type" }],
|
||||
"vault": { "types", "totalNotes" },
|
||||
"referencedNotes": [{ "title", "path", "type" }]
|
||||
}
|
||||
```
|
||||
|
||||
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
|
||||
Large active notes are compacted into a head/tail body snapshot before they enter the CLI prompt. The snapshot records `bodyTruncated` metadata and instructs agents to call `get_note(path)` before content-sensitive edits or summaries, keeping lower-context OpenCode providers from failing on oversized active-note context while preserving access to the full note through MCP.
|
||||
|
||||
### Direct Model Targets
|
||||
|
||||
Tolaria also supports direct model targets for local servers and API providers. These targets are stored as app-level provider metadata and can be selected in Settings or the status bar alongside coding agents. `src/shared/aiModelProviderCatalog.json` is the shared source for provider defaults, local/API grouping, API-key environment placeholders, and runtime fallback base URLs; the renderer imports it through `aiTargets.ts`, and Tauri includes the same JSON in `ai_models.rs`. Direct model targets run in Chat mode: they receive the same note-context snapshot and conversation history, but they do not receive vault-write tools or shell access. The backend `stream_ai_model` command supports OpenAI-compatible chat completions and Anthropic Messages-compatible calls, including Ollama, LM Studio, OpenRouter, OpenAI, Anthropic, Gemini, and custom compatible endpoints.
|
||||
|
||||
Provider secrets are not written to `settings.json`. Hosted API targets can use Tolaria's local app-data secrets file (`ai-provider-secrets.json`, outside vaults/worktrees and owner-only on Unix) or reference an environment variable name. Local endpoints can omit authentication.
|
||||
|
||||
### Authentication
|
||||
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed. Tolaria does not store model-provider API keys in app settings.
|
||||
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; OpenCode surfaces a friendly prompt to run `opencode auth login` or configure a provider when needed; Pi surfaces a friendly prompt to run `pi /login` or configure a provider API key when needed. Tolaria does not store model-provider API keys in app settings; direct provider secrets stay in local app data or user-managed environment variables.
|
||||
|
||||
## MCP Server
|
||||
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Gemini CLI, Cursor, or any MCP-compatible client).
|
||||
|
||||
### Tool Surface (14 tools)
|
||||
|
||||
@@ -305,19 +351,21 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
|
||||
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
|
||||
|
||||
### Auto-Registration
|
||||
### Explicit External Tool Setup
|
||||
|
||||
On app startup, Tolaria automatically registers itself as an MCP server in:
|
||||
- `~/.claude/mcp.json` (Claude Code)
|
||||
Tolaria can register itself as an MCP server in:
|
||||
- `~/.claude.json` and `~/.claude/mcp.json` (Claude Code compatibility across current CLI and legacy MCP-file setups)
|
||||
- `~/.gemini/settings.json` (Gemini CLI)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
- `~/.config/mcp/mcp.json` (generic MCP-compatible clients)
|
||||
|
||||
Registration is non-destructive (additive, preserves other servers) and uses `upsert` semantics. The `useMcpStatus` hook tracks registration state (`checking | installed | not_installed | no_claude_cli`).
|
||||
That setup is user-initiated through the status bar / command palette flow, not a startup side effect. Registration is non-destructive (additive, preserves other servers and Gemini settings), uses `upsert` semantics, and can be reversed by removing Tolaria's entry again. Tolaria verifies Node.js is available before writing config, writes an explicit `type: "stdio"` entry, pins `VAULT_PATH` to the active vault, and sets `WS_UI_PORT=9711` so UI actions route back to the desktop app. The same generated entry is exposed as a manual JSON snippet in the MCP setup dialog and through the AI panel copy action, giving users a transparent fallback for MCP-compatible tools Tolaria does not auto-configure. In the desktop app, `useMcpStatus` copies that snippet through the native `copy_text_to_clipboard` command instead of the Web Clipboard API so macOS WKWebView permission policy cannot block setup. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux package roots such as `/usr/local/Tolaria`, `/usr/lib/tolaria`, and `/usr/lib/tolaria/resources`, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`) and owns connect, disconnect, exact-snippet load, and copy-to-clipboard actions. Gemini CLI still owns its own install and sign-in; Tolaria writes the durable external MCP entry only on explicit setup, while app-managed Gemini sessions use transient settings and optional vault guidance. The desktop WebSocket bridge is started only when a persisted active vault exists and is resynced from React state on vault changes; no selected vault stops the bridge instead of falling back to `~/Laputa`. Stdio MCP server processes are owned by the external client that launched them: when that client closes stdin, Tolaria cancels UI-bridge reconnect timers, closes any UI WebSocket, and exits the Node process instead of keeping it alive in the background.
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph MCP["MCP Server (Node.js) — spawned by Tauri on startup"]
|
||||
subgraph MCP["MCP Server (Node.js) — selected-vault scoped"]
|
||||
IDX["index.js"]
|
||||
VAULT["vault.js\n(findMarkdownFiles, readNote, createNote,\nsearchNotes, appendToNote, editNoteFrontmatter,\ndeleteNote, linkNotes, listNotes, vaultContext)"]
|
||||
WSB["ws-bridge.js"]
|
||||
@@ -329,8 +377,8 @@ flowchart TD
|
||||
WSB -->|"port 9711 — UI bridge"| FE["Frontend\n(useAiActivity)"]
|
||||
end
|
||||
|
||||
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
|
||||
TAURI -->|"auto-register"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"]
|
||||
TAURI["Tauri bridge lifecycle"] -->|"start/stop/restart with active VAULT_PATH"| MCP
|
||||
UI["Status bar / Command Palette"] -->|"explicit setup or disconnect"| CFG["~/.claude.json\n~/.claude/mcp.json\n~/.cursor/mcp.json\n~/.config/mcp/mcp.json"]
|
||||
```
|
||||
|
||||
### WebSocket Bridge
|
||||
@@ -357,10 +405,13 @@ flowchart LR
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
|
||||
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs |
|
||||
| `sync_mcp_bridge_vault(vault_path?)` | Starts, restarts, or stops the desktop WebSocket bridge as the selected vault changes |
|
||||
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Gemini CLI, Cursor, and generic MCP configs on user request |
|
||||
| `mcp_config_snippet(vault_path)` | Builds the exact `mcpServers.tolaria` JSON users can copy into any compatible client without writing third-party config files |
|
||||
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, Gemini CLI, Cursor, and generic MCP configs |
|
||||
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is replaced on vault switches, stopped when no active vault is selected, and killed plus waited on app exit via the `RunEvent::Exit` handler. The same desktop layer keeps Tauri asset protocol access limited to vault roots loaded during the current app session; command calls remain active-vault scoped for reads, writes, and external opens.
|
||||
|
||||
## Search
|
||||
|
||||
@@ -379,7 +430,9 @@ The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning usin
|
||||
|
||||
### Cache File
|
||||
|
||||
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan). Writes are atomic (write to `.tmp` then rename). Legacy `.laputa-cache.json` files inside the vault are auto-migrated and deleted on first run.
|
||||
`~/.laputa/cache/<vault-hash>.json` — stored outside the vault directory so it never pollutes the user's git repo. The vault path is normalized through `vault/path_identity.rs` before hashing, so macOS `/tmp` aliases and separator variants share the same cache identity. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
|
||||
|
||||
`<vault>/.tolaria-rename-txn/` — hidden, scan-ignored staging directory for crash-safe note renames. Tolaria stores temporary backup files plus one manifest per in-flight rename here. On the next vault scan, unfinished transactions are recovered before entries are listed so users do not see a missing note or a visible duplicate after a crash.
|
||||
|
||||
### Three Cache Strategies
|
||||
|
||||
@@ -391,7 +444,7 @@ flowchart TD
|
||||
D -->|Same commit| E["🟢 Cache Hit\ngit status --porcelain\n→ re-parse only uncommitted changes"]
|
||||
D -->|Different commit| F["🟡 Incremental Update\ngit diff old..new --name-only\n→ selective re-parse of changed files"]
|
||||
|
||||
C --> G[Write cache atomically\n.tmp → rename]
|
||||
C --> G[Replace cache if unchanged\nwriter lock + temp file → rename]
|
||||
E --> G
|
||||
F --> G
|
||||
G --> H([VaultEntry list ready])
|
||||
@@ -399,10 +452,17 @@ flowchart TD
|
||||
|
||||
## Styling
|
||||
|
||||
The app uses a single light theme with no user-configurable theming (see [ADR-0013](adr/0013-remove-theming-system.md)).
|
||||
The app uses internal app-owned light and dark themes with an optional System preference (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md) and [ADR-0112](adr/0112-system-theme-mode.md)). This is not the old vault-authored theming system from ADR-0013: users choose a mode, but themes are owned by the app.
|
||||
|
||||
1. **Global CSS variables** (`src/index.css`): App-wide colors, borders, backgrounds. Bridged to Tailwind v4 via `@theme inline`.
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`.
|
||||
1. **Global CSS variables** (`src/index.css`): Semantic app colors, borders, surfaces, and interaction states. Bridged to Tailwind v4 via `@theme inline`.
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`; editor colors resolve through the same semantic app variables.
|
||||
3. **Theme runtime**: Applies resolved `light` / `dark` values to `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode or System-on-dark is selected. Settings and command-palette theme actions both write the same installation-local `settings.theme_mode` value; `system` subscribes to `prefers-color-scheme` changes at runtime while explicit Light/Dark remain overrides.
|
||||
|
||||
## Localization
|
||||
|
||||
Tolaria's app chrome uses an app-owned localization runtime in `src/lib/i18n.ts`, backed by flat JSON catalogs in `src/lib/locales/` and Lara CLI synchronization through `lara.yaml` (see [ADR-0087](adr/0087-json-catalogs-and-lara-cli-localization.md)). `en.json` is the canonical source catalog, locale files are one file per locale, and English remains the fallback for any missing locale file or key. The installation-local `ui_language` setting stores an explicit locale when the user chooses one; `null` means "follow the system language when Tolaria supports it, otherwise English." Legacy stored values such as `zh-Hans` are normalized to canonical locale codes like `zh-CN`.
|
||||
|
||||
`App.tsx` derives the effective locale from settings and browser/system language hints, then passes it down to localized surfaces. Settings exposes a keyboard-accessible shadcn `Select`, and the command palette includes actions to open language settings or switch directly to a supported language.
|
||||
|
||||
## Vault Management
|
||||
|
||||
@@ -425,6 +485,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
- `zoom`: Float zoom level (0.8–1.5)
|
||||
- `view_mode`: "all" | "editor-list" | "editor-only"
|
||||
- `editor_mode`: "raw" | "preview" (persists across note switches and sessions)
|
||||
- `note_layout`: "centered" | "left" (wide-screen note column alignment for rich and raw editors)
|
||||
- `tag_colors`, `status_colors`: Custom color overrides
|
||||
- `property_display_modes`: Property display preferences
|
||||
- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list
|
||||
@@ -435,14 +496,20 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
|
||||
On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options:
|
||||
- **Create a new vault** → creates an empty git repo in a folder the user chooses
|
||||
- **Open an existing folder** → system file picker
|
||||
- **Open an existing folder** → system file picker; plain Markdown folders without `.git` open immediately in supported non-git mode
|
||||
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault.
|
||||
|
||||
When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action.
|
||||
|
||||
When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup and remote-connection commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria <vault@tolaria.md>` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures.
|
||||
|
||||
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, and Gemini 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. 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. 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.
|
||||
|
||||
@@ -453,9 +520,10 @@ Tolaria no longer implements provider-specific OAuth or remote-repository APIs.
|
||||
**Flow:**
|
||||
1. User opens `CloneVaultModal` from onboarding or the vault menu
|
||||
2. User pastes any git URL and chooses a local destination
|
||||
3. `clone_repo()` shells out to `git clone`
|
||||
4. `git_push()` / `git_pull()` continue to use the same system git path
|
||||
5. If auth fails, the raw git stderr is surfaced in the UI
|
||||
3. The `clone_git_repo()` Tauri command runs `git clone` inside a blocking Tokio task so the Tauri window stays responsive during slow or failing clones
|
||||
4. Linux AppImage builds strip AppImage loader variables from system-git subprocesses before spawning `git`, keeping `git-remote-https` on the host git/library stack
|
||||
5. `git_push()` / `git_pull()` continue to use the same system git path
|
||||
6. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input
|
||||
|
||||
**Auth model:**
|
||||
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
|
||||
@@ -486,8 +554,9 @@ sequenceDiagram
|
||||
participant MCP as MCP Server
|
||||
participant U as User
|
||||
|
||||
T->>T: run_startup_tasks()<br/>(register MCP)
|
||||
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
|
||||
T->>T: apply Linux AppImage WebKit env/preload safeguards<br/>(AppImage only)
|
||||
T->>T: start background legacy vault housekeeping<br/>(does not block setup)
|
||||
T->>MCP: start background initial ws-bridge sync<br/>(if active vault exists)
|
||||
T->>A: App mounts
|
||||
|
||||
A->>A: useOnboarding — vault exists?
|
||||
@@ -495,10 +564,16 @@ sequenceDiagram
|
||||
A-->>U: WelcomeScreen
|
||||
else Vault found
|
||||
A->>VL: useVaultLoader fires
|
||||
VL->>T: invoke('list_vault') → scan_vault_cached()
|
||||
VL->>T: invoke('reload_vault') → allow requested vault roots in asset scope + scan_vault_cached()
|
||||
T-->>VL: VaultEntry[]
|
||||
VL->>T: invoke('get_modified_files')
|
||||
VL->>T: useMcpStatus — register if needed
|
||||
alt Runtime vault path disappears
|
||||
VL->>T: invoke('check_vault_exists')
|
||||
VL-->>A: unavailable vault path + cleared stale state
|
||||
A-->>U: WelcomeScreen (vault missing)
|
||||
end
|
||||
A->>T: useMcpStatus — check explicit MCP setup state
|
||||
A->>T: sync_mcp_bridge_vault(selected path)
|
||||
VL-->>A: entries ready
|
||||
end
|
||||
|
||||
@@ -506,9 +581,10 @@ sequenceDiagram
|
||||
A->>T: invoke('get_note_content')
|
||||
T-->>A: raw markdown
|
||||
A->>A: splitFrontmatter → [yaml, body]
|
||||
A->>A: preProcessDurableEditorMarkdown(body)
|
||||
A->>A: preProcessWikilinks(body)
|
||||
A->>A: tryParseMarkdownToBlocks()
|
||||
A->>A: injectWikilinks(blocks)
|
||||
A->>A: injectWikilinks + injectDurableEditorMarkdownBlocks(blocks)
|
||||
A-->>U: Editor renders note
|
||||
```
|
||||
|
||||
@@ -559,7 +635,9 @@ flowchart TD
|
||||
|
||||
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
|
||||
|
||||
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
|
||||
If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. 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.
|
||||
|
||||
The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command ensures the local author identity, adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds.
|
||||
|
||||
`useCommitFlow` also exposes `runAutomaticCheckpoint()`, a dialog-free commit path shared by AutoGit and the bottom-bar Commit button. `useAutoGit` watches the last editor activity plus app focus/visibility state, and when the vault is git-backed, all saves are flushed, and no unsaved edits remain, it triggers the same deterministic `Updated N note(s)` / `Updated N file(s)` commit message path after the configured idle or inactive thresholds. The bottom-bar quick action reuses that checkpoint flow after forcing a save first, so manual quick commits and scheduled AutoGit commits stay aligned on message generation and push behavior.
|
||||
|
||||
@@ -583,10 +661,12 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
|
||||
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `ignored.rs` | Gitignored-content visibility filtering via batched, pipe-safe `git check-ignore` |
|
||||
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
|
||||
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
|
||||
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
|
||||
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
|
||||
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` shims), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
|
||||
| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault |
|
||||
|
||||
## Rust Backend Modules
|
||||
@@ -596,15 +676,17 @@ 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`) |
|
||||
| `search.rs` | Keyword search — walkdir-based vault file scan |
|
||||
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
|
||||
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
|
||||
| `mcp.rs` | MCP server spawning + config registration |
|
||||
| `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 |
|
||||
| `claude_cli.rs`, `codex_cli.rs`, `opencode_cli.rs`, `pi_cli.rs`, `gemini_cli.rs` | CLI-agent command/config/event adapters |
|
||||
| `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing |
|
||||
| `mcp.rs` | MCP server spawning + explicit config registration/removal |
|
||||
| `commands/` | Tauri command handlers (split into submodules) |
|
||||
| `settings.rs` | App settings persistence |
|
||||
| `vault_config.rs` | Per-vault UI config |
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
| `menu.rs` | Native desktop menu definitions and command IDs (not mounted on Linux) |
|
||||
|
||||
## Tauri IPC Commands
|
||||
|
||||
@@ -612,23 +694,27 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
|
||||
| `list_vault` | Scan vault (cached), then apply Gitignored-content visibility → `Vec<VaultEntry>` |
|
||||
| `get_note_content` | Read note file content |
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Permanently delete note from disk (with confirm dialog) |
|
||||
| `rename_note` | Rename note + update `title` frontmatter + cross-vault wikilinks |
|
||||
| `rename_note` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts |
|
||||
| `move_note_to_folder` | Crash-safe folder move that preserves the filename, reloads the moved note, and rewrites path-based wikilinks |
|
||||
| `create_vault_folder` | Create a folder relative to the active vault root |
|
||||
| `list_vault_folders` | Build the folder tree on the blocking Tokio pool, then apply Gitignored-content visibility → `Vec<FolderNode>` |
|
||||
| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths |
|
||||
| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root |
|
||||
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_delete_notes` | Permanently delete notes from disk |
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault` | Allow the requested vault roots in the runtime asset scope, invalidate cache, full rescan from filesystem, then apply Gitignored-content visibility → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `open_vault_file_external` | Validate an existing file against the active vault boundary, then open it with the system default app |
|
||||
| `start_vault_watcher` / `stop_vault_watcher` | Start or stop native active-vault filesystem change events |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
|
||||
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
|
||||
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
|
||||
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md`, `CLAUDE.md`, and optional `GEMINI.md` guidance are managed, missing, broken, or custom |
|
||||
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
|
||||
|
||||
### Frontmatter
|
||||
@@ -642,6 +728,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `init_git_repo` | Initialize a local repo, add default `.gitignore`, and create the unsigned setup commit |
|
||||
| `git_commit` | Stage all + commit |
|
||||
| `git_pull` | Pull from remote |
|
||||
| `git_push` | Push to remote |
|
||||
@@ -678,12 +765,16 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
|
||||
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
|
||||
| `check_claude_cli` | Check if Claude CLI is available |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex availability |
|
||||
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Cursor config |
|
||||
| `check_mcp_status` | Check MCP registration state |
|
||||
| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini availability |
|
||||
| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, or Gemini through the normalized agent event layer |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Gemini/Cursor/generic config for the active vault |
|
||||
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/generic config |
|
||||
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config |
|
||||
| `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault |
|
||||
| `copy_text_to_clipboard` | Copy setup snippets through the native desktop clipboard command path |
|
||||
| `read_text_from_clipboard` | Read current desktop clipboard text for command-driven plain-text paste |
|
||||
| `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected |
|
||||
|
||||
The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly.
|
||||
|
||||
@@ -699,8 +790,8 @@ The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bri
|
||||
| `save_vault_config` | Save per-vault UI config |
|
||||
| `get_default_vault_path` | Get default vault path |
|
||||
| `get_build_number` | Get app build number |
|
||||
| `save_image` | Save base64 image to vault |
|
||||
| `copy_image_to_vault` | Copy image file to vault |
|
||||
| `save_image` | Save base64 image to `attachments/` and ensure the vault root is in the runtime asset scope |
|
||||
| `copy_image_to_vault` | Copy image file to `attachments/` and ensure the vault root is in the runtime asset scope |
|
||||
| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions |
|
||||
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
|
||||
| `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes |
|
||||
@@ -733,20 +824,23 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
|
||||
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
|
||||
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
|
||||
| `useNoteRename` | — | Note renaming with wikilink update |
|
||||
| `useNoteRename` | — | Note renaming and folder moves with wikilink update |
|
||||
| `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions |
|
||||
| `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 |
|
||||
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
| `useTheme` | Editor theme CSS vars | Editor typography theme |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
|
||||
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
|
||||
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation backed by the shared session pipeline and vault permission mode |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useAutoGit` | Last activity timestamp, idle/inactive checkpoint triggers | Automatic commit/push checkpoints |
|
||||
| `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration |
|
||||
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
|
||||
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
|
||||
| `useSettings` | App settings (telemetry, release channel, auto-sync interval, AutoGit thresholds, default AI agent) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config |
|
||||
| `appCommandDispatcher` | Manifest-backed shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
|
||||
|
||||
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
|
||||
|
||||
@@ -758,20 +852,27 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
| Cmd+P / Cmd+O | Open quick open palette |
|
||||
| Cmd+N | Create new note |
|
||||
| Cmd+S | Save current note |
|
||||
| Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it |
|
||||
| Cmd+Shift+F | Find in vault |
|
||||
| Cmd+Shift+V | Paste without Formatting into the active supported editing surface |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) |
|
||||
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
|
||||
| Cmd+1–9 | Switch to tab N |
|
||||
| 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 "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context 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.
|
||||
|
||||
Shortcut routing is explicit:
|
||||
|
||||
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
|
||||
- `src/shared/appCommandManifest.json` is the shared command/menu metadata source for command IDs, menu labels, accelerators, native-menu enablement groups, and deterministic QA flags
|
||||
- `appCommandCatalog.ts` derives renderer command IDs, shortcut lookup maps, Linux menu sections, and QA metadata from that manifest
|
||||
- `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators
|
||||
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
|
||||
- macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
|
||||
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
|
||||
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
|
||||
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow`
|
||||
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
|
||||
- Deterministic QA uses two explicit proof paths from the shared manifest:
|
||||
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
|
||||
@@ -792,9 +893,13 @@ push to main
|
||||
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
|
||||
→ build job:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
|
||||
→ upload signed .app.tar.gz + .sig updater artifacts
|
||||
→ 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
|
||||
→ release job:
|
||||
→ generate alpha-latest.json
|
||||
→ 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
|
||||
→ pages job:
|
||||
→ build static HTML release history page
|
||||
@@ -811,13 +916,20 @@ push stable-vYYYY.M.D tag
|
||||
→ version job: validate YYYY.M.D from the tag
|
||||
→ build job:
|
||||
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
|
||||
→ upload signed .app.tar.gz + .sig and .dmg artifacts
|
||||
→ 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,appimage
|
||||
→ upload .deb, .AppImage, and signed Linux updater bundles
|
||||
→ 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
|
||||
→ release job:
|
||||
→ generate stable-latest.json with both updater tarball and current stable DMG URLs
|
||||
→ generate stable-latest.json with macOS Apple Silicon, macOS Intel, Linux, and Windows updater URLs plus platform-specific manual download URLs
|
||||
→ publish GitHub release Tolaria YYYY.M.D
|
||||
→ pages job:
|
||||
→ publish stable/latest.json
|
||||
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable DMG
|
||||
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable platform installer
|
||||
→ preserve alpha/latest.json
|
||||
→ deploy to gh-pages
|
||||
```
|
||||
@@ -858,7 +970,7 @@ sequenceDiagram
|
||||
App->>User: TelemetryConsentDialog
|
||||
alt Accept
|
||||
User->>Settings: telemetry_consent=true, anonymous_id=UUID
|
||||
Settings->>Sentry: init(DSN, anonymous_id)
|
||||
Settings->>Sentry: init(DSN, release, anonymous_id)
|
||||
Settings->>PostHog: init(key, anonymous_id)
|
||||
else Decline
|
||||
User->>Settings: telemetry_consent=false
|
||||
@@ -876,10 +988,12 @@ sequenceDiagram
|
||||
- `anonymous_id` is a locally-generated UUID, never tied to identity
|
||||
- `send_default_pii: false` on both SDKs
|
||||
- PostHog: `autocapture: false`, `persistence: 'memory'`, no cookies
|
||||
- Product events use categorical metadata only: file preview kind/action, AI agent id/permission mode/counts/status, and All Notes visibility category/enabled state.
|
||||
|
||||
**Architecture:**
|
||||
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
|
||||
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook
|
||||
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook; the React root also wires `onCaughtError`, `onUncaughtError`, and `onRecoverableError` through `Sentry.reactErrorHandler()` so production React invariants include component stack context when crash reporting is enabled.
|
||||
- **Release grouping:** packaged release workflows pass `VITE_SENTRY_RELEASE` from the computed build version, but the app only assigns Sentry's `release` field for stable calendar builds (`YYYY.M.D`). Alpha/prerelease/internal builds omit `release` so they do not create normal Sentry Releases entries, while both frontend and Rust Sentry scopes tag `tolaria.build_version` and `tolaria.release_kind` for diagnostics.
|
||||
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
|
||||
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`
|
||||
|
||||
@@ -934,7 +1048,7 @@ Desktop-only modules gated at the crate level:
|
||||
Desktop-only features gated at the function level in `commands/`:
|
||||
- Git operations (commit, pull, push, status, history, diff, conflicts)
|
||||
- Clone-by-URL via system git (`clone_repo`)
|
||||
- CLI AI agent streaming (Claude, Codex)
|
||||
- CLI AI agent streaming (Claude, Codex, OpenCode, Pi, Gemini)
|
||||
- MCP registration and status
|
||||
- Menu state updates
|
||||
|
||||
|
||||
@@ -8,6 +8,43 @@ How to navigate the codebase, run the app, and find what you need.
|
||||
- **Rust** 1.77.2+ (for the Tauri backend)
|
||||
- **git** CLI (required by the git integration features)
|
||||
|
||||
### Linux system dependencies
|
||||
|
||||
If you run the desktop app on Linux, install Tauri's WebKit2GTK 4.1 dependencies first:
|
||||
|
||||
- Arch / Manjaro:
|
||||
```bash
|
||||
sudo pacman -S --needed webkit2gtk-4.1 base-devel curl wget file openssl \
|
||||
appmenu-gtk-module libappindicator-gtk3 librsvg
|
||||
```
|
||||
- Debian / Ubuntu (22.04+):
|
||||
```bash
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||
libsoup-3.0-dev patchelf
|
||||
```
|
||||
- Fedora 38+:
|
||||
```bash
|
||||
sudo dnf install webkit2gtk4.1-devel openssl-devel curl wget file \
|
||||
libappindicator-gtk3-devel librsvg2-devel
|
||||
```
|
||||
|
||||
### Linux AppImage Wayland troubleshooting
|
||||
|
||||
On some Wayland systems, the Linux AppImage may fail to launch with:
|
||||
|
||||
```text
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
|
||||
|
||||
```bash
|
||||
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib64/libwayland-client.so.0 ./Tolaria*.AppImage
|
||||
```
|
||||
|
||||
If your distribution stores the 64-bit library elsewhere, use that path instead, for example `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`. On 64-bit Fedora, avoid `/usr/lib/libwayland-client.so.0`; that path can point at a 32-bit library and be ignored by the loader with a wrong ELF class warning.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -32,6 +69,8 @@ pnpm playwright:regression # Full Playwright regression suite
|
||||
|
||||
`create_getting_started_vault` clones the public starter repo and then removes every git remote from the new local copy. That means Getting Started vaults open local-only by default. Users connect a compatible remote later through the bottom-bar `No remote` chip or the command palette, both of which feed the same `AddRemoteModal` and `git_add_remote` backend flow.
|
||||
|
||||
Linux AppImage builds still use the user's system `git`. Before Tolaria spawns that `git` process, it removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` so HTTPS clone helpers use the host git libraries instead of bundled AppImage libraries.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
@@ -42,8 +81,8 @@ tolaria/
|
||||
│ ├── App.css # App shell layout styles
|
||||
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
|
||||
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
|
||||
│ ├── theme.json # Editor theme configuration
|
||||
│ ├── index.css # Global CSS variables + Tailwind setup
|
||||
│ ├── theme.json # Editor typography theme configuration
|
||||
│ ├── index.css # Semantic app theme variables + Tailwind setup
|
||||
│ │
|
||||
│ ├── components/ # UI components (~98 files)
|
||||
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
|
||||
@@ -58,7 +97,7 @@ tolaria/
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent)
|
||||
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent + per-vault permission mode)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
|
||||
@@ -68,6 +107,8 @@ tolaria/
|
||||
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
|
||||
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── LinuxTitlebar.tsx # Linux-only custom window chrome + controls
|
||||
│ │ ├── LinuxMenuButton.tsx # Linux titlebar menu mirroring app commands
|
||||
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
|
||||
│ │ ├── AddRemoteModal.tsx # Connect a local-only vault to a remote later
|
||||
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
|
||||
@@ -83,16 +124,16 @@ tolaria/
|
||||
│ │ └── ui/ # shadcn/ui primitives
|
||||
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
|
||||
│ │
|
||||
│ ├── hooks/ # Custom React hooks (~87 files)
|
||||
│ ├── hooks/ # Custom React hooks (~86 files)
|
||||
│ │ ├── useVaultLoader.ts # Loads vault entries + content
|
||||
│ │ ├── useVaultSwitcher.ts # Multi-vault management
|
||||
│ │ ├── useVaultConfig.ts # Per-vault UI settings
|
||||
│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter
|
||||
│ │ ├── useNoteCreation.ts # Note/type creation
|
||||
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
|
||||
│ │ ├── useAiAgent.ts # Legacy Claude-specific stream helpers reused by the shared agent hook
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized tool tracking
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex availability polling
|
||||
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline
|
||||
│ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels
|
||||
│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini availability polling
|
||||
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
│ │ ├── useAutoSync.ts # Auto git pull/push
|
||||
@@ -111,13 +152,15 @@ tolaria/
|
||||
│ │ ├── useOnboarding.ts # First-launch flow
|
||||
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
|
||||
│ │ ├── useMcpBridge.ts # MCP WebSocket client
|
||||
│ │ ├── useMcpStatus.ts # MCP registration status
|
||||
│ │ ├── useMcpStatus.ts # Explicit external AI tool connection status + connect/disconnect actions
|
||||
│ │ ├── useUpdater.ts # In-app updates
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── utils/ # Pure utility functions (~48 files)
|
||||
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML parser
|
||||
│ │ ├── plainTextPaste.ts # Shared Paste without Formatting command target registry
|
||||
│ │ ├── platform.ts # Runtime platform + Linux chrome gating helpers
|
||||
│ │ ├── ai-agent.ts # Agent stream utilities
|
||||
│ │ ├── ai-chat.ts # Token estimation utilities
|
||||
│ │ ├── ai-context.ts # Context snapshot builder
|
||||
@@ -133,6 +176,8 @@ tolaria/
|
||||
│ ├── lib/
|
||||
│ │ ├── aiAgents.ts # Shared agent registry + status helpers
|
||||
│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands
|
||||
│ │ ├── i18n.ts # App-owned localization runtime and locale resolution
|
||||
│ │ ├── locales/ # JSON locale catalogs (English source + translated locales)
|
||||
│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers
|
||||
│ │ └── utils.ts # Tailwind merge + cn() helper
|
||||
│ │
|
||||
@@ -163,9 +208,12 @@ tolaria/
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── telemetry.rs # Sentry init + path scrubber
|
||||
│ │ ├── search.rs # Keyword search (walkdir-based)
|
||||
│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + registration
|
||||
│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch
|
||||
│ │ ├── cli_agent_runtime.rs # Shared CLI-agent runtime process/prompt/MCP helpers
|
||||
│ │ ├── claude_cli.rs # Claude CLI adapter
|
||||
│ │ ├── codex_cli.rs # Codex CLI adapter
|
||||
│ │ ├── pi_cli.rs # Pi CLI adapter
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal
|
||||
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
│ │ ├── vault_config.rs # Per-vault UI config
|
||||
@@ -187,6 +235,7 @@ tolaria/
|
||||
├── scripts/ # Build/utility scripts
|
||||
│
|
||||
├── package.json # Frontend dependencies + scripts
|
||||
├── lara.yaml # Lara CLI locale sync configuration
|
||||
├── vite.config.ts # Vite bundler config
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── playwright.config.ts # Full Playwright regression config
|
||||
@@ -234,8 +283,9 @@ tolaria/
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). |
|
||||
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
|
||||
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, Codex adapter, and stream normalization. |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
| `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. |
|
||||
| `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. |
|
||||
| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. |
|
||||
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
|
||||
|
||||
### Editor
|
||||
@@ -254,8 +304,11 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, actions, and per-vault permission mode. |
|
||||
| `src/hooks/useCliAiAgent.ts` | Thin React owner for the selected CLI agent session state. |
|
||||
| `src/lib/aiAgentSession.ts` | Single message/session lifecycle for prompt normalization, history, streaming, and reset behavior. |
|
||||
| `src/lib/aiAgentPermissionMode.ts` | Safe/Power User mode normalization, display labels, and local transcript marker text. |
|
||||
| `src/lib/aiAgentFileOperations.ts` | Detects agent-created or modified vault files from normalized tool inputs. |
|
||||
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
|
||||
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
|
||||
|
||||
@@ -263,19 +316,19 @@ tolaria/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/index.css` | All CSS custom properties. The design token source of truth. |
|
||||
| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). |
|
||||
| `src/index.css` | Semantic CSS custom properties for app-owned light/dark themes; System mode resolves to one of these at runtime. |
|
||||
| `src/theme.json` | Editor-specific typography theme (fonts, headings, lists, code blocks). |
|
||||
|
||||
### Settings & Config
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval, default AI agent). |
|
||||
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, auto-sync interval, default note width, sidebar type pluralization, default AI agent). |
|
||||
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
|
||||
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
|
||||
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. |
|
||||
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
|
||||
|
||||
## Architecture Patterns
|
||||
@@ -311,10 +364,12 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the Light/Dark/System theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
Current-note find/replace is a surface-aware command: editor focus enables "Find in Note" / "Replace in Note" and routes Cmd+F into raw CodeMirror mode; note-list focus enables existing note-list search instead. When adding another focus-dependent command, mirror this pattern with an availability event consumed by `useMenuEvents.ts` and `update_menu_state`.
|
||||
|
||||
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
|
||||
|
||||
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
|
||||
@@ -354,7 +409,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.)
|
||||
2. Add a command handler in `commands/`
|
||||
3. Register it in the `generate_handler![]` macro in `lib.rs`
|
||||
4. Call it from the frontend via `invoke()` in the appropriate hook
|
||||
4. Call it from the frontend via `invoke()` in the appropriate hook or utility, keeping native-only permission work behind the Tauri command boundary
|
||||
5. Add a mock handler in `mock-tauri.ts`
|
||||
|
||||
### Add a new component
|
||||
@@ -366,7 +421,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
### Add a new entity type
|
||||
|
||||
1. Create a type document: `type/mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
|
||||
1. Create a type document at the vault root: `mytype.md` with `type: Type` frontmatter (icon, color, order, etc.)
|
||||
2. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
|
||||
3. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
|
||||
4. Notes of this type are created at the vault root with `type: MyType` in frontmatter — no dedicated folder needed
|
||||
@@ -380,7 +435,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
|
||||
### Modify styling
|
||||
|
||||
1. **Global CSS variables**: Edit `src/index.css`
|
||||
1. **Global app/theme variables**: Edit `src/index.css`
|
||||
2. **Editor typography**: Edit `src/theme.json`
|
||||
|
||||
### Work with the AI agent
|
||||
@@ -388,5 +443,14 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
|
||||
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
|
||||
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs`
|
||||
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_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103.
|
||||
|
||||
### Work with external MCP setup
|
||||
|
||||
1. **Backend registration/status/snippets**: Edit `src-tauri/src/mcp.rs`; registration and manual config generation must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux package roots (`/usr/local/Tolaria`, `/usr/local/lib/tolaria`, `/usr/lib/tolaria`, `/usr/lib/tolaria/resources`), and AppImage installs, and use an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
|
||||
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx` and `src/hooks/useMcpStatus.ts`; users should see the Node.js prerequisite, the exact generated manual config, and a copy action before Tolaria writes third-party config files
|
||||
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes
|
||||
4. **Gemini CLI compatibility**: Keep `~/.gemini/settings.json` in the registration path list and keep optional `GEMINI.md` generation behind `restore_vault_ai_guidance`; app-managed Gemini sessions still require the user to install and sign in to Gemini CLI, but Tolaria supplies transient MCP settings when Gemini is selected as the default AI agent
|
||||
5. **Process lifecycle**: Stdio MCP servers in `mcp-server/index.js` must exit when their external client closes stdin, and the desktop-owned `ws-bridge.js` child must be stopped on vault deselection, vault switch, and app exit
|
||||
|
||||
53
docs/LARGE-VAULT-LOADING-QA.md
Normal file
53
docs/LARGE-VAULT-LOADING-QA.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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.
|
||||
@@ -2,8 +2,9 @@
|
||||
type: ADR
|
||||
id: "0013"
|
||||
title: "Remove vault-based theming system"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-03-23
|
||||
superseded_by: "0081"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
type: ADR
|
||||
id: "0071"
|
||||
title: "External vault updates reload derived state and reopen the clean active note"
|
||||
status: active
|
||||
status: superseded
|
||||
date: 2026-04-21
|
||||
superseded_by: "0111"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0074"
|
||||
title: "Explicit external AI tool setup and least-privilege desktop scope"
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's first MCP integration optimized for zero setup: desktop startup auto-registered the Tolaria MCP server in Claude Code and Cursor config files, the Tauri asset protocol allowed every local path, and app-managed Codex sessions launched with the CLI's dangerous bypass flag. That made the product feel convenient, but it also widened trust by default in places that users could not see or consent to clearly.
|
||||
|
||||
The product direction now favors least-privilege defaults. Fresh installs should not silently edit third-party config files, external AI tool setup must be intentional and reversible, and the desktop shell should only expose the filesystem paths that the active vault actually needs.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria now treats external AI tool wiring as an explicit user action and keeps the desktop shell scoped to the active vault.**
|
||||
|
||||
- The app still spawns its local MCP WebSocket bridge on desktop startup, but it no longer auto-registers third-party MCP config files.
|
||||
- External MCP registration is exposed through a keyboard-accessible setup flow reachable from the command palette and status surfaces. Confirming the flow upserts Tolaria's MCP entry for the current vault; cancel leaves external config untouched; disconnect removes Tolaria's entry again.
|
||||
- The Tauri asset protocol remains enabled for local vault images, but its static config scope is empty. Tolaria grants recursive asset access only to the active vault at runtime when that vault is reloaded.
|
||||
- App-managed Codex sessions use the CLI's normal approval and sandbox path by default instead of opting into the dangerous bypass mode automatically.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Explicit setup + runtime vault-only scope** (chosen): aligns with least-privilege defaults, keeps command-palette discoverability, preserves image loading and external-tool support, and makes every privileged step visible and reversible.
|
||||
- **Keep startup auto-registration and global asset scope**: lowest friction, but it silently mutates third-party config and leaves the desktop shell effectively open to every local file path.
|
||||
- **Disable external MCP registration entirely**: safest on paper, but it removes a valuable workflow for Claude Code, Cursor, and other MCP-compatible tools that Tolaria intentionally supports.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Fresh installs no longer modify `~/.claude/mcp.json` or `~/.cursor/mcp.json` until the user confirms setup.
|
||||
- Switching vaults does not silently retarget external MCP clients; users reconnect explicitly when they want a different vault exposed.
|
||||
- Desktop asset access is constrained to the active vault instead of all filesystem paths, while note images and attachments continue to load normally.
|
||||
- The command palette and status bar now expose an explicit external AI tools setup/remove flow that supports keyboard-only QA.
|
||||
- Codex agent sessions are safer by default, at the cost of relying on the CLI's normal approval path instead of bypassing it automatically.
|
||||
36
docs/adr/0075-crash-safe-note-rename-transactions.md
Normal file
36
docs/adr/0075-crash-safe-note-rename-transactions.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0075"
|
||||
title: "Crash-safe note rename transactions"
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's note rename path used a simple "write new file, then delete old file" flow. That was easy to implement, but it had three integrity problems called out by issue #205: it could leave a visible duplicate note if the app crashed between those steps, destination-path selection depended on check-then-use races, and backlink rewrite failures were collapsed into a generic updated-files count that made partial success look clean.
|
||||
|
||||
Rename is a core vault integrity operation. The app needs a flow that preserves a trustworthy visible state even when the process dies mid-rename, and it needs to surface any partial backlink rewrite failures clearly enough that users are not told everything updated when some linked files still need manual repair.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria now stages note renames through a hidden per-vault transaction directory and recovers unfinished transactions on the next vault scan.**
|
||||
|
||||
- A rename that changes the file path first writes a transaction manifest plus a hidden backup path inside `<vault>/.tolaria-rename-txn/`.
|
||||
- Tolaria moves the old note into that hidden backup, persists the new file with a no-clobber destination write, and then deletes the backup/manifest only after the new note exists.
|
||||
- If the process crashes before the new note is committed, the next `scan_vault` restores the hidden backup back to the original path before listing entries.
|
||||
- Manual filename renames keep their explicit conflict semantics, but the final destination is now claimed with a no-clobber write instead of relying on an existence check.
|
||||
- Backlink rewrites now return both the number of successful updates and the number of failed updates so the UI can warn about partial success instead of reporting a clean rename.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Hidden transaction directory + scan-time recovery** (chosen): keeps in-flight rename artifacts out of the visible vault model, gives Tolaria a deterministic recovery point after crashes, and lets the final destination use no-clobber persistence.
|
||||
- **Rename in place without transaction metadata**: simpler, but it cannot recover a half-finished rename reliably after process death and still leaves either duplicate or missing-note windows.
|
||||
- **Best-effort duplicate cleanup with no recovery path**: lowest implementation cost, but it leaves the user-visible vault state dependent on exact crash timing and does not meet the trustworthiness goal for rename operations.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every vault can now contain a hidden `.tolaria-rename-txn/` directory managed by Tolaria; scan and folder UI continue to ignore it because hidden directories are already excluded.
|
||||
- Rename results are richer: the frontend must treat `failed_updates > 0` as a warning state even when the rename itself succeeded.
|
||||
- Future changes to vault scanning or note rename behavior must preserve transaction recovery before entry listing, otherwise crash safety regresses.
|
||||
- The rename path no longer silently overwrites a destination discovered via a stale existence check; title-driven renames retry with suffixed filenames, while explicit filename renames fail cleanly on collision.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0076"
|
||||
title: "Note retargeting separates type changes from folder moves"
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0025 made `type:` the canonical classification field, and ADR-0033 reopened subfolders as a valid way to organize files in the vault. Once Tolaria exposed both type sections and the folder tree in the sidebar, note reorganization had an unresolved ambiguity: does retargeting a note mean changing its semantic type, moving its file, or both?
|
||||
|
||||
Without an explicit model, drag-and-drop and command-palette flows would need to duplicate their own validation and persistence logic, and Tolaria could easily drift back toward the old type-folder coupling that ADR-0006 deliberately removed.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria treats note retargeting as one shared interaction model with two distinct mutation paths: types change metadata, folders change file paths.**
|
||||
|
||||
- Retargeting a note to a type updates only the note's `type:` frontmatter. The file stays where it is.
|
||||
- Retargeting a note to a folder preserves the current filename and `type:` value, and moves the file through the same crash-safe rename transaction pipeline used for backend rename commands.
|
||||
- Drag/drop targets and command-palette actions both route through the same frontend retargeting abstraction so validation, dialogs, collision handling, and success/error behavior stay consistent.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Shared retargeting model with separate type-vs-folder semantics** (chosen): preserves ADR-0025/ADR-0006's decoupling of type from path, lets folder moves reuse ADR-0075's crash-safe rename guarantees, and keeps multiple UI surfaces behaviorally aligned.
|
||||
- **Treat folders as the source of truth for note type**: simpler mental model for some vaults, but it reintroduces path-based type inference and makes type changes depend on file moves again.
|
||||
- **Implement drag/drop and command-palette retargeting as separate flows**: lower short-term coordination cost, but it duplicates mutation rules and makes consistency regressions likely.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Type sections are semantic targets only; they must never imply a filesystem move.
|
||||
- Folder targets are physical move operations; they must preserve filename/title behavior, reject collisions, and rewrite path-based wikilinks through the shared rename pipeline.
|
||||
- Future note-retargeting surfaces should reuse the shared retargeting abstraction instead of introducing another mutation path.
|
||||
- Re-evaluate this ADR if Tolaria later supports bulk retargeting, folder rules that intentionally infer type, or another organization primitive that needs different semantics.
|
||||
37
docs/adr/0077-concurrent-safe-vault-cache-replacement.md
Normal file
37
docs/adr/0077-concurrent-safe-vault-cache-replacement.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0077"
|
||||
title: "Concurrent-safe vault cache replacement"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0014 and ADR-0024 established Tolaria's git-based persistent vault cache and moved it outside the vault directory. That cache was still being rewritten with a simple temp-file-and-rename flow.
|
||||
|
||||
Once Tolaria started reopening the same vault from multiple windows/processes more often, that write model became too optimistic: two scans could both build valid cache payloads from different moments in time, and the slower writer could still atomically replace a fresher cache written by another window. The cache needed cross-window safety without introducing a long-lived coordinator process or making vault open dependent on heavyweight IPC.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria now treats vault-cache replacement as a best-effort compare-and-swap operation instead of an unconditional atomic overwrite.**
|
||||
|
||||
- Each scan still builds the next cache payload in memory and writes it to a temp file first.
|
||||
- Before replacing the real cache file, Tolaria acquires a short-lived lock file for that vault cache path.
|
||||
- After the lock is acquired, Tolaria rechecks the on-disk cache fingerprint and only renames the temp file into place if another window/process has not already refreshed the cache.
|
||||
- If the cache changed underneath the current scan, Tolaria skips the replace and keeps the newer on-disk cache.
|
||||
- Stale cache-write locks are garbage-collected after a short timeout so a crashed writer does not block future refreshes.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Lock + fingerprint guarded replacement** (chosen): keeps the cache file external and file-based, avoids overwriting fresher cache state from another Tolaria window, and preserves graceful fallback to filesystem rescans. Cons: cache writes become best-effort rather than guaranteed after every scan.
|
||||
- **Keep unconditional temp-file + rename**: simplest implementation, but concurrent windows can regress the cache to an older view even though each individual replace is atomic.
|
||||
- **Centralized cache service or long-lived process mutex**: strongest coordination story, but too much operational complexity for a local desktop app and would create new failure modes around boot, process lifetime, and IPC.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tolaria's cache correctness model is now "latest successful guarded replace wins," not "every scan must write a cache file."
|
||||
- Cache refreshes must tolerate a skipped write when another window/process already produced a fresher cache.
|
||||
- Temp-file writes and renames still provide corruption resistance, but freshness is protected separately by the writer lock and fingerprint check.
|
||||
- Cache-write failures remain non-fatal: Tolaria logs them and falls back to rebuilding from the filesystem when needed.
|
||||
- Re-evaluate if Tolaria later needs stronger cross-process coordination than lock-file + fingerprint checks can provide.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0078"
|
||||
title: "Scoped unsigned fallback for app-managed git commits"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0021, ADR-0059, and ADR-0070 all assume Tolaria can create and advance a local git-backed vault without asking users to debug git internals first. In practice, inherited `commit.gpgsign` settings were breaking that promise: a missing or misconfigured GPG/SSH signing helper could block the initial `Initial vault setup` commit during onboarding and could also strand later app-triggered commits behind opaque signing failures.
|
||||
|
||||
Tolaria needed a policy that kept signed workflows intact when the user's signing setup actually works, while still ensuring app-managed git operations do not become unusable just because a desktop environment cannot reach the signing helper.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses a scoped unsigned fallback for app-managed commits instead of requiring signing to succeed unconditionally.**
|
||||
|
||||
- The onboarding/setup commit (`Initial vault setup`) always runs with `commit.gpgsign=false` for that single git invocation.
|
||||
- Normal app-managed `git_commit` calls still honor the user's existing git signing configuration first.
|
||||
- If a commit fails and Git's error matches a signing-helper failure, Tolaria retries that same app-managed commit once with signing disabled.
|
||||
- Tolaria does not rewrite the user's git config and does not broaden the retry to unrelated commit failures.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Scoped unsigned fallback for app-managed commits** (chosen): keeps onboarding and local commit flows resilient while still preserving signed commits when the user's environment supports them. Cons: some Tolaria-created commits may be unsigned on machines with broken signing setups.
|
||||
- **Require signing to succeed for every commit**: simplest policy, but it turns missing desktop GPG/SSH helpers into app-breaking failures during onboarding and normal use.
|
||||
- **Disable signing for all Tolaria-triggered commits**: maximally robust, but it would silently bypass working signing setups and weaken users' expected git security posture.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New vault creation is no longer blocked by inherited signing settings that only fail in Tolaria's app context.
|
||||
- Users with healthy signing setups still get signed Tolaria commits after the first normal attempt succeeds.
|
||||
- Signing-failure detection must stay narrow so Tolaria does not mask unrelated git errors behind an unsigned retry.
|
||||
- Tolaria's git integration now explicitly prefers "complete the app-managed commit safely" over "preserve signing at all costs" when the signing helper is unavailable.
|
||||
- Re-evaluate if Tolaria later exposes per-vault git policy controls or needs a richer user-facing explanation for when a fallback unsigned commit was used.
|
||||
37
docs/adr/0079-linux-window-chrome-and-menu-reuse.md
Normal file
37
docs/adr/0079-linux-window-chrome-and-menu-reuse.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0079"
|
||||
title: "Linux window chrome and menu reuse"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's desktop shell was designed around macOS window chrome. `titleBarStyle: "Overlay"` and `hiddenTitle: true` give the app a clean single-surface titlebar on macOS, but Linux ignores those flags and draws native GTK decorations and a native menu bar on top of the React UI. That creates a double-titlebar effect, mismatched theming, and inconsistent behavior between the main window and detached note windows.
|
||||
|
||||
We still need Linux to reuse Tolaria's existing command palette, shortcut manifest, and deterministic menu-command routing instead of inventing a Linux-only command path.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses custom React-rendered window chrome on Linux and routes its menu through the existing shared command IDs.**
|
||||
|
||||
- The main Tauri window disables server-side decorations on Linux during app setup.
|
||||
- Detached note windows set `decorations: false` when Linux chrome is active.
|
||||
- `LinuxTitlebar` renders the drag region, resize handles, and window controls for Linux windows.
|
||||
- `LinuxMenuButton` mirrors the app's File/Edit/View/Go/Note/Vault/Window menus, but dispatches the existing command IDs through `trigger_menu_command`.
|
||||
- The native Tauri menu bar is not mounted on Linux; macOS and other existing desktop targets keep the native menu.
|
||||
- Shared shortcuts remain defined in `appCommandCatalog.ts`, including `Cmd+Shift+L` on macOS and `Ctrl+Shift+L` on Linux through the same command manifest.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **React-rendered Linux chrome with shared command IDs** (chosen): keeps Linux visually aligned with Tolaria's existing shell and preserves one command-routing model across keyboard shortcuts, menu clicks, and QA helpers. Cons: Tolaria now owns Linux window chrome behavior directly.
|
||||
- **Keep native GTK decorations and menu bar on Linux**: cheaper to ship, but it breaks visual consistency and produces overlapping titlebar/menu surfaces that do not match the rest of the app.
|
||||
- **Introduce Linux-only command wiring for the custom menu**: would allow a Linux-specific implementation, but it would fork the shortcut/menu architecture and weaken deterministic QA.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Linux main windows and detached note windows now present one consistent titlebar surface controlled by Tolaria.
|
||||
- Menu commands, command palette actions, and deterministic QA still share the same command IDs, which limits platform-specific drift.
|
||||
- Linux packaging and CI must install WebKit2GTK 4.1 dependencies and produce Linux bundles explicitly.
|
||||
- Tolaria now owns Linux resize handles, maximize/minimize/close behavior, and titlebar drag-region behavior in the renderer, so regressions in those surfaces require direct tests.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0080"
|
||||
title: "Cross-platform desktop release artifacts and portable vault names"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's release pipeline and file validation rules were still biased toward macOS. Alpha/stable releases only produced first-class macOS artifacts, stable download redirects assumed a DMG-only world, and vault file/folder validation allowed names that work on macOS/Linux but break on Windows clones and sync targets.
|
||||
|
||||
Shipping Windows as a supported desktop target requires both distribution and data portability to become explicit. A Windows installer is not enough if shared vault content can still produce invalid filenames on that platform, and cross-platform updater manifests must keep Tauri's signed updater artifact separate from the user-facing installer download.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria ships first-class macOS, Windows x64, and Linux x64 desktop artifacts, and its vault-facing filename rules are portable across those platforms by default.**
|
||||
|
||||
- Alpha and stable release workflows build and publish macOS, Windows x64, and Linux x64 artifacts from the same release tag/version computation.
|
||||
- `latest.json` manifests continue to point Tauri updater clients at signed updater artifacts through `url`, while manual installer/download links are exposed separately via platform-specific fields such as `dmg_url` and `download_url`.
|
||||
- The stable download page resolves the best current platform download from that manifest plus release assets, instead of assuming macOS-only DMG delivery.
|
||||
- Note filename renames, folder creation/rename flows, and custom view filenames all share one portable validation rule set that rejects Windows reserved device names, invalid characters, and trailing dot/space suffixes.
|
||||
- Shortcut labels shown in the UI are derived from the shared command manifest so non-macOS builds display `Ctrl`-style accelerators instead of macOS glyphs.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Cross-platform artifacts + portable filename rules** (chosen): makes Windows support real instead of nominal, keeps updater behavior compatible with Tauri, and prevents cross-OS vault breakage at the point of write. Cons: more CI matrix surface area and more platform-specific packaging constraints.
|
||||
- **Ship Windows installers but keep existing filename validation**: lowers immediate implementation cost, but Windows users would still hit invalid vault content created elsewhere and trust in sync portability would stay weak.
|
||||
- **Keep macOS-first updater/download metadata and infer other platforms from release assets only**: cheaper in the short term, but it weakens in-app update guarantees and makes the public download page depend on ad hoc asset naming rather than an explicit manifest contract.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tolaria's release CI now owns packaging and artifact validation on three desktop platforms instead of one.
|
||||
- The public stable download page can redirect Windows/Linux users to real installers without special-case manual curation.
|
||||
- Vault content created through Tolaria stays portable across macOS, Linux, and Windows, which reduces sync-time surprises and broken clones.
|
||||
- Any future platform addition now needs both a release-artifact contract and an explicit portable-filename review instead of piggybacking on macOS assumptions.
|
||||
43
docs/adr/0081-internal-light-dark-theme-runtime.md
Normal file
43
docs/adr/0081-internal-light-dark-theme-runtime.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0081"
|
||||
title: "Internal light and dark theme runtime"
|
||||
status: active
|
||||
date: 2026-04-24
|
||||
supersedes: "0013"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0013 removed the vault-authored theming system and made Tolaria light-only. That kept the app simpler, but dark mode has become a product requirement for long writing sessions and accessibility.
|
||||
|
||||
The previous theming system should not return in its old form: vault notes, live user-authored themes, and broad runtime editing created too much maintenance burden. Tolaria still needs a small app-owned theme architecture because the UI spans Tailwind/shadcn variables, BlockNote/Mantine surfaces, CodeMirror raw editing, syntax highlighting, and product-specific states such as selected rows, badges, warnings, and diff lines.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support internal app-owned light and dark themes through a semantic CSS-variable contract, with the user's theme mode persisted as installation-local app settings.**
|
||||
|
||||
The v1 theme runtime is deliberately smaller than a general theming system:
|
||||
|
||||
- Themes are defined by the app, not by vault-authored notes.
|
||||
- CSS custom properties remain the public runtime contract for product components, Tailwind v4, and shadcn/ui.
|
||||
- Typed TypeScript helpers may derive values for consumers that cannot read CSS variables directly, such as CodeMirror extensions.
|
||||
- Existing CSS variables stay available as compatibility aliases while the UI migrates toward semantic names.
|
||||
- The first persisted choices are `light` and `dark`; system-follow, high-contrast variants, custom themes, and per-vault themes are deferred.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Internal light/dark runtime with semantic tokens** (chosen): ships dark mode while keeping the product-owned theme surface small, testable, and compatible with existing CSS-variable usage.
|
||||
- **Reintroduce vault-authored theme notes**: flexible, but repeats the complexity removed by ADR-0013 and makes dark mode dependent on user-editable data.
|
||||
- **Ad hoc `.dark` overrides in components**: fastest initially, but would scatter color logic across the app and make future theme variants expensive.
|
||||
- **Single TypeScript theme object as source of truth**: attractive for validation, but the current app already relies on CSS variables for Tailwind, shadcn/ui, BlockNote CSS overrides, and many product components.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/index.css` owns the stable CSS custom-property contract for app chrome and shared states.
|
||||
- `src/theme.json` continues to describe editor typography, but editor-facing colors should resolve through the same semantic CSS variables used by the app shell.
|
||||
- `useTheme` remains responsible for editor theme flattening and can grow into the bridge between app theme mode and editor consumers.
|
||||
- App settings, not vault frontmatter, store the selected theme mode because it is an installation-local comfort preference.
|
||||
- Startup must avoid a light-mode flash when dark mode is selected, so the runtime needs a pre-React localStorage mirror and a minimal `index.html` prepaint style in addition to persisted Tauri settings.
|
||||
- Domain tokens should be introduced only when a surface needs a role that generic semantic tokens cannot express clearly.
|
||||
- Re-evaluate if Tolaria decides to support user-authored custom themes, per-vault themes, or system-synchronized mode as a first-class product requirement.
|
||||
40
docs/adr/0082-markdown-durable-math-notes.md
Normal file
40
docs/adr/0082-markdown-durable-math-notes.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0082"
|
||||
title: "Markdown-durable math in notes"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are durable Markdown files, while the main editor uses BlockNote and raw mode uses CodeMirror. Users coming from technical note-taking tools expect inline math such as `$E=mc^2$` and display math such as `$$ ... $$` to render inside notes without turning the note into an app-only document format.
|
||||
|
||||
BlockNote does not currently ship a first-party math block in the local editor package. Tiptap now offers an official Mathematics extension that renders KaTeX nodes, but Tolaria's save path still depends on BlockNote's Markdown parser and `blocksToMarkdownLossy()` serializer. Adding opaque ProseMirror math nodes without an explicit Tolaria serializer would risk losing or rewriting the original Markdown source.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support note math through a Markdown placeholder round-trip owned by the editor pipeline.**
|
||||
|
||||
The initial implementation:
|
||||
|
||||
- Treats `$...$` as inline math and line-owned `$$...$$` / multiline `$$` blocks as display math.
|
||||
- Converts math source to temporary placeholders before BlockNote parses Markdown.
|
||||
- Replaces placeholders with Tolaria schema nodes that render via the existing `katex` dependency.
|
||||
- Serializes those schema nodes back to the original Markdown delimiters before saving or entering raw mode.
|
||||
- Uses KaTeX with `throwOnError: false` and `trust: false` so malformed or untrusted formulas remain visible rather than breaking the note.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Tolaria-owned placeholder round-trip with KaTeX rendering** (chosen): matches the existing wikilink architecture, preserves plain-text source, and avoids depending on BlockNote support for non-default ProseMirror math nodes.
|
||||
- **Tiptap Mathematics extension directly in BlockNote**: attractive because it is official Tiptap and KaTeX-backed, but it does not by itself solve Tolaria's BlockNote Markdown serializer contract.
|
||||
- **Raw-mode-only math support**: preserves source but fails the rich editor reading experience users expect.
|
||||
- **Store formulas as custom JSON/frontmatter metadata**: richer structured editing later, but violates the Markdown-first durability requirement.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/utils/mathMarkdown.ts` is the canonical parser/serializer bridge for note math.
|
||||
- The rich editor renders math as schema nodes; raw mode remains the most direct way to edit exact math source.
|
||||
- CodeMirror raw editing keeps the literal Markdown delimiters, so imported Obsidian-style notes remain understandable outside Tolaria.
|
||||
- Future equation editing helpers can be added on top of the same Markdown source contract instead of changing the storage model.
|
||||
- Re-evaluate direct Tiptap Mathematics integration only if it can be proven to preserve Tolaria's Markdown save path without custom lossy behavior.
|
||||
38
docs/adr/0083-dual-architecture-macos-release-artifacts.md
Normal file
38
docs/adr/0083-dual-architecture-macos-release-artifacts.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0083"
|
||||
title: "Dual-architecture macOS release artifacts"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0080"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0080 made Tolaria's desktop release pipeline cross-platform, but the macOS leg still shipped only Apple Silicon artifacts. That left Intel Mac users without a compatible build in both the alpha feed and stable releases, even though Tauri and Rust can produce `x86_64-apple-darwin` bundles from the same release workflow.
|
||||
|
||||
The updater manifest also needs to distinguish macOS CPU architectures. A generic `darwin` or macOS-only entry would make it too easy for an Intel installation to see an Apple Silicon updater bundle, and browser user agents cannot reliably tell Apple Silicon Macs apart from Intel Macs.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria publishes macOS release artifacts for both Apple Silicon (`darwin-aarch64`) and Intel (`darwin-x86_64`) in every alpha and stable release.**
|
||||
|
||||
- Alpha and stable workflows build the macOS matrix for `aarch64-apple-darwin` and `x86_64-apple-darwin`.
|
||||
- Alpha manifests include signed updater tarballs for `darwin-aarch64` and `darwin-x86_64`.
|
||||
- Stable manifests include both macOS updater tarballs and both manual DMG downloads, alongside the existing Windows x64 and Linux x86_64 entries.
|
||||
- Release jobs normalize macOS artifact filenames with the architecture suffix before publishing so GitHub release assets stay unambiguous.
|
||||
- The stable download page exposes separate Apple Silicon and Intel Mac links. When both Mac links exist, a generic macOS browser is not auto-redirected because user-agent architecture detection is unreliable.
|
||||
- The cross-platform filename portability decisions from ADR-0080 remain in force.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Publish separate Apple Silicon and Intel Mac artifacts** (chosen): gives each updater client an architecture-specific manifest key and gives users explicit manual download links. Cons: doubles the macOS release matrix and signing/notarization surface.
|
||||
- **Publish a universal macOS binary**: gives users one download, but requires lipo/re-sign/notarize coordination and reintroduces the artifact-combining complexity the release pipeline intentionally avoided.
|
||||
- **Keep Apple Silicon-only macOS releases**: keeps CI cheaper, but leaves Intel Mac users unsupported and makes the release artifacts inconsistent with Tolaria's desktop support goals.
|
||||
|
||||
## Consequences
|
||||
|
||||
- macOS release jobs now run one matrix entry per CPU architecture.
|
||||
- Release manifest consumers must treat `darwin-aarch64` and `darwin-x86_64` as distinct platform keys.
|
||||
- Stable manual downloads show two Mac choices instead of pretending browser detection can select the right CPU architecture.
|
||||
- Future macOS release changes must validate both updater and manual-download artifacts for both architectures.
|
||||
35
docs/adr/0084-app-localization-foundation.md
Normal file
35
docs/adr/0084-app-localization-foundation.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0084"
|
||||
title: "App-owned localization foundation"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria was effectively English-only. Users requested a general i18n foundation and Chinese-language support. We need a path that lets the UI adopt additional locales without pushing UI-language preferences into vault files or making every partially translated string a runtime failure.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria owns a dependency-free frontend localization layer in `src/lib/i18n.ts`.
|
||||
|
||||
- English is the canonical fallback locale.
|
||||
- Simplified Chinese (`zh-Hans`) is the first additional locale.
|
||||
- `ui_language` is an installation-local app setting in `~/.config/com.tolaria.app/settings.json`; `null` means "follow system language when supported, otherwise English".
|
||||
- Missing translation keys fall back to English.
|
||||
- App-level chrome receives locale through props from `App.tsx`, following the existing props-down/callbacks-up architecture instead of introducing global React context.
|
||||
- Language switching is exposed in Settings and through command-palette actions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Add an i18n dependency now**: useful long term, but unnecessary for the first locale and would add framework surface before we know Tolaria's locale workflow.
|
||||
- **Store language in the vault**: rejected because UI language is an installation preference, not content structure.
|
||||
- **Translate ad hoc strings inline**: rejected because it would make fallback behavior inconsistent and future locales expensive.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New UI strings should be added to `src/lib/i18n.ts` first and rendered through `translate()` / `createTranslator()` where the surface already receives locale.
|
||||
- Partially translated locales remain usable because English is the fallback for missing keys.
|
||||
- Locale choice changes UI chrome immediately after settings save or command-palette language commands without reopening the vault.
|
||||
- Larger feature surfaces can migrate to the shared localization module incrementally.
|
||||
39
docs/adr/0085-non-git-vault-support.md
Normal file
39
docs/adr/0085-non-git-vault-support.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0085"
|
||||
title: "Non-git vaults open with explicit later Git initialization"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0034"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0034 made Git a hard prerequisite for opening a vault because Git-backed cache, history, change, and sync flows failed invisibly when users opened plain Markdown folders. That protected Git features, but it blocked the common adoption path of opening an existing folder from Obsidian, iCloud, Dropbox, or a manually maintained notes directory.
|
||||
|
||||
Tolaria now needs the opposite default: browsing and editing Markdown should work immediately, while Git remains an explicit capability users can enable when they want history, sync, commits, or collaboration.
|
||||
|
||||
## Decision
|
||||
|
||||
**Open existing Markdown folders even when they are not Git repositories.** A non-git vault is a supported state, not an error state. On open, Tolaria asks whether to initialize Git; if the user dismisses the prompt, the app keeps working and the status bar permanently shows a `Git disabled` warning. Clicking that warning, or running `Initialize Git for Current Vault` from the command palette, reopens the setup action.
|
||||
|
||||
While a vault is not Git-backed:
|
||||
|
||||
- Git history, change, commit, sync, conflict, and remote actions are hidden or disabled.
|
||||
- Background auto-sync and AutoGit checkpoints do not run.
|
||||
- Markdown scanning, note browsing, note editing, search, and non-Git vault features continue normally.
|
||||
|
||||
`init_git_repo` remains the single backend command for enabling Git later. It creates the repository, writes Tolaria's default `.gitignore`, stages the vault, and creates the unsigned setup commit.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A (chosen): Supported non-git mode with explicit later initialization.** Best adoption path; keeps Git capabilities visible without blocking the basic notes workflow.
|
||||
- **Option B: Keep the ADR-0034 blocking modal.** Prevents Git feature ambiguity, but rejects valid plain-folder workflows.
|
||||
- **Option C: Auto-initialize Git when opening a plain folder.** Low friction, but surprising for users who do not want Tolaria to mutate folder metadata.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Existing Git-backed vaults keep the same history, commit, sync, and remote behavior.
|
||||
- UI surfaces must treat Git capability as stateful per vault, not as an app-wide invariant.
|
||||
- Tests need to cover both Git-backed and non-git vaults in browser mocks and native QA.
|
||||
- Future Git-dependent features must check the current vault's Git state before registering commands or running background work.
|
||||
32
docs/adr/0086-in-app-image-file-preview.md
Normal file
32
docs/adr/0086-in-app-image-file-preview.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0086"
|
||||
title: "In-app image previews for binary vault files"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0041"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0041 made the vault scanner index all visible files and introduced `fileKind` as `"markdown"`, `"text"`, or `"binary"`. Binary files were deliberately shown as inert entries until Tolaria had a dedicated preview model.
|
||||
|
||||
That made image references visible in folder views, but opening an image still felt outside the normal Tolaria workflow. Users need to inspect screenshots, diagrams, and other image assets while keeping their place in the vault.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria previews supported image files in the editor pane while keeping them as ordinary binary `VaultEntry` files.**
|
||||
|
||||
- The scanner keeps the existing `fileKind: "binary"` representation. Image previewability is inferred in the renderer from the file extension, not by introducing a proprietary image document type.
|
||||
- Opening a binary entry creates the same single active-tab state used for notes, but with empty content and no `get_note_content` text read.
|
||||
- `FilePreview` renders supported image extensions through Tauri's asset protocol (`convertFileSrc`) so the original file remains on disk.
|
||||
- Broken images and unsupported binary files render an explicit fallback state with an intentional "Open in default app" action instead of launching another app automatically.
|
||||
- Note-list rows use an image indicator for previewable image binaries. Unsupported binary rows remain muted and non-clickable in the normal list surface.
|
||||
- The preview surface is keyboard focusable and `Escape` returns focus to the note list, matching the app's keyboard-first navigation model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The existing `VaultEntry` model and cache version do not need to change.
|
||||
- Supported image files can participate in normal selection/navigation context without being converted into Markdown notes.
|
||||
- Unsupported/broken binary files have a clear in-app state when reached through navigation paths that can select them.
|
||||
- Any future PDF, audio, or video preview should extend the same file-preview renderer rather than adding new vault-owned document representations.
|
||||
39
docs/adr/0087-json-catalogs-and-lara-cli-localization.md
Normal file
39
docs/adr/0087-json-catalogs-and-lara-cli-localization.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0087"
|
||||
title: "JSON locale catalogs with Lara CLI synchronization"
|
||||
status: active
|
||||
date: 2026-04-27
|
||||
supersedes:
|
||||
- "0084"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0084 established an app-owned localization layer in `src/lib/i18n.ts` with English fallback and hand-maintained TypeScript dictionaries. That was enough for the first localized UI surface, but it does not scale well to a broader locale matrix or machine-assisted translation workflows.
|
||||
|
||||
We now want Tolaria to support a wider set of locales and to automate translation updates with Lara CLI while keeping the runtime dependency-light and preserving the existing English fallback behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria will keep its app-owned runtime localization layer, but the translation source-of-truth moves to flat JSON catalogs in `src/lib/locales/`.
|
||||
|
||||
- `src/lib/locales/en.json` is the canonical source catalog.
|
||||
- Additional locale files use one JSON file per locale code (for example `zh-CN.json`, `fr-FR.json`).
|
||||
- `src/lib/i18n.ts` keeps fallback, interpolation, locale resolution, and props-down locale wiring, but it now loads locale catalogs from JSON files instead of TypeScript objects.
|
||||
- Lara CLI configuration lives in `lara.yaml`, and translation runs happen through repo scripts (`pnpm l10n:translate`, `pnpm l10n:translate:force`).
|
||||
- `scripts/validate-locales.mjs` verifies that every locale catalog present in the repo matches the English keyset and only contains flat string values.
|
||||
- Legacy stored preferences such as `zh-Hans` are normalized to the canonical `zh-CN` locale.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep TypeScript dictionaries and point Lara at `.ts` files**: possible, but JSON is the more standard interchange format for translation tooling and keeps diffs simpler for translators and reviewers.
|
||||
- **Adopt a full frontend i18n framework now**: rejected because Tolaria already has working locale propagation and fallback behavior, and the immediate need is better content management plus translation automation.
|
||||
- **Store translated strings outside the app repo**: rejected because Tolaria's chrome localization should stay versioned with the app code that consumes it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Translators and automation tools now work against plain JSON catalogs instead of editing source code.
|
||||
- The runtime keeps English fallback behavior, so a missing locale file or missing key does not break app chrome.
|
||||
- Locale additions become a data/config change first: add the locale metadata, run Lara, review JSON output, then ship.
|
||||
- Localization work now has a dedicated validation step that can run in CI or before commit.
|
||||
40
docs/adr/0088-markdown-durable-mermaid-diagrams.md
Normal file
40
docs/adr/0088-markdown-durable-mermaid-diagrams.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0088"
|
||||
title: "Markdown-durable Mermaid diagrams in notes"
|
||||
status: active
|
||||
date: 2026-04-27
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are plain Markdown files, while the rich editor uses BlockNote and raw mode uses CodeMirror. Users need fenced `mermaid` blocks to render as diagrams in the note surface without changing the canonical file format or hiding the source from raw editing.
|
||||
|
||||
BlockNote can parse fenced code blocks, but a generic highlighted code block does not provide diagram rendering. Rendering Mermaid directly from the Markdown fence also has to preserve the original fence source when notes are saved, copied through raw mode, closed, and reopened.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support Mermaid diagrams through a Markdown placeholder round-trip owned by the editor pipeline and rendered with the `mermaid` package.**
|
||||
|
||||
The implementation:
|
||||
|
||||
- Converts fenced `mermaid` blocks to temporary placeholders before BlockNote parses Markdown.
|
||||
- Replaces placeholders with a `mermaidBlock` schema block that stores both the original fenced source and the diagram body.
|
||||
- Renders the block through Mermaid in the rich editor.
|
||||
- Serializes `mermaidBlock` nodes back to their stored fenced Markdown before save, raw-mode entry, and editor-position snapshots.
|
||||
- Shows the original source as an inline fallback when Mermaid cannot render a diagram.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Tolaria-owned placeholder round-trip with Mermaid rendering** (chosen): matches the existing wikilink and math architecture, keeps Markdown as the source of truth, and gives Tolaria explicit control over serialization.
|
||||
- **Render all `mermaid` code blocks by overriding the generic code-block renderer**: smaller surface, but it couples diagram behavior to the code-highlighting package and makes exact source preservation harder.
|
||||
- **Raw-mode-only Mermaid support**: preserves source but fails the enhanced note reading experience users expect.
|
||||
- **Store parsed diagram metadata outside the Markdown body**: enables richer future editing, but violates the files-first model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/utils/mermaidMarkdown.ts` is the canonical parser/serializer bridge for note diagrams.
|
||||
- Rich mode renders diagrams as schema-backed blocks; raw mode remains the direct source editor.
|
||||
- Invalid Mermaid source remains visible instead of breaking the editor surface.
|
||||
- `mermaid` is now a runtime dependency and should be upgraded deliberately with rendering regression coverage.
|
||||
- Future diagram controls, such as copy source or expand, can attach to the same `mermaidBlock` without changing storage.
|
||||
36
docs/adr/0089-active-vault-filesystem-watcher.md
Normal file
36
docs/adr/0089-active-vault-filesystem-watcher.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0089"
|
||||
title: "Active vault filesystem watcher"
|
||||
status: active
|
||||
date: 2026-04-27
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria treats the filesystem as the source of truth, but before this decision the running app only noticed external file changes after a manual Reload Vault, a Git pull, or an AI-agent-specific refresh callback. Edits from another editor, terminal commands, another Tolaria window, or a non-pull Git operation could leave React state and the editor surface stale.
|
||||
|
||||
ADR-0071 already defines the safe reconciliation policy for external vault mutations: reload vault-derived state, protect unsaved local edits, and reopen the clean active note from disk when needed. Filesystem watching needed to reuse that policy instead of adding another ad hoc reload path.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria watches the active desktop vault with a native filesystem watcher and routes external change batches through the shared external-refresh reconciler.**
|
||||
|
||||
The desktop backend exposes `start_vault_watcher` and `stop_vault_watcher` commands backed by Rust `notify`. It watches the active vault recursively, ignores known non-content churn such as `.git/`, `node_modules/`, temp files, and `.tolaria-rename-txn`, then emits `vault-changed` events with the active vault path and changed paths.
|
||||
|
||||
The renderer owns batching and reconciliation. `useVaultWatcher` starts the backend watcher for the active main-window vault, debounces native events into one refresh, filters out recent app-owned saves, and calls `refreshPulledVaultState()`. Manual Reload Vault still uses `reload_vault` directly, but now exposes visible reload feedback.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Native active-vault watcher plus shared reconciliation** (chosen): keeps external changes visible without polling and preserves ADR-0071 behavior for clean and unsaved tabs. Cons: adds one native dependency and a long-lived watcher state.
|
||||
- **Frontend-only polling**: simpler backend surface, but wastes work on idle vaults and still needs careful active-note reconciliation.
|
||||
- **Direct `reloadVault()` on every native event**: easy to implement, but bypasses clean-tab reopen handling and can clobber the user experience around unsaved edits.
|
||||
- **Watch every configured vault**: could pre-warm state, but burns resources for inactive vaults and complicates event ownership across windows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- External writes converge automatically into the visible vault state after a short debounce.
|
||||
- Active clean notes are refreshed through the same path as pull and AI-agent updates; unsaved local edits remain protected.
|
||||
- Tolaria app-owned saves are suppressed briefly so autosave does not trigger a full external refresh loop.
|
||||
- The status bar can show reload progress for manual and automatic refreshes.
|
||||
- The watcher is a desktop-only integration; mobile builds keep no-op command stubs until a mobile-specific filesystem strategy exists.
|
||||
35
docs/adr/0090-pi-cli-agent-adapter.md
Normal file
35
docs/adr/0090-pi-cli-agent-adapter.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0090"
|
||||
title: "Pi CLI agent adapter"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already supports Claude Code, Codex, and OpenCode as local CLI agents in the AI panel. The next provider request is Pi Coding Agent support with the same first-class availability, settings, status, streaming, and MCP vault access.
|
||||
|
||||
Pi exposes non-interactive JSON events through `pi --mode json`, but Pi core intentionally does not include built-in MCP. Its supported MCP path is the `pi-mcp-adapter` extension, which reads MCP server definitions from Pi-compatible config files.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria adds Pi as a supported CLI agent id (`pi`) and launches app-managed Pi sessions through a dedicated adapter module.
|
||||
|
||||
The adapter runs `pi --mode json --no-session` from the active vault cwd, closes stdin, and points `PI_CODING_AGENT_DIR` at a temporary directory containing Tolaria's `mcp.json`. That config loads the Tolaria MCP server through `pi-mcp-adapter`, pins `VAULT_PATH` to the selected vault, sets `WS_UI_PORT=9711`, uses lazy server lifecycle, and exposes the small Tolaria tool set directly.
|
||||
|
||||
Pi availability follows the existing desktop pattern: check the inherited `PATH`, the user's login shell, and common local/toolchain install locations. Pi authentication remains owned by the Pi CLI; Tolaria only surfaces setup errors and does not store provider API keys.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Transient Pi adapter config** (chosen): gives app-launched Pi sessions Tolaria MCP access without mutating user or vault config files. Cons: requires the Pi MCP adapter extension path to be available to Pi.
|
||||
- **Write `.mcp.json` into the active vault**: simple for Pi discovery, but creates project files as a side effect of a chat session and can dirty user vaults.
|
||||
- **Rely on global `~/.pi/agent/mcp.json`**: matches Pi documentation, but silently retargets or depends on user-global state and conflicts with Tolaria's explicit integration boundary.
|
||||
- **Skip MCP for Pi**: easier to implement, but creates a weaker agent than the existing providers and violates the requirement for first-class MCP support.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The AI panel, settings, command palette, status bar, onboarding, and stream normalization now treat Pi as a first-class supported agent.
|
||||
- App-launched Pi sessions can use Tolaria vault MCP tools while remaining scoped to the selected vault.
|
||||
- Pi support stays isolated in Pi-specific modules instead of expanding the shared `ai_agents.rs` hotspot.
|
||||
- Users still need Pi itself, a configured Pi model/provider, and the Pi MCP adapter extension path available to the Pi CLI.
|
||||
35
docs/adr/0091-gemini-cli-external-ai-setup.md
Normal file
35
docs/adr/0091-gemini-cli-external-ai-setup.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0091"
|
||||
title: "Gemini CLI external AI setup"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already supports explicit MCP setup for external desktop AI tools. Users asked for Gemini CLI support so the same active-vault MCP server can be registered where Gemini reads tool configuration, with optional Gemini-specific vault guidance.
|
||||
|
||||
Gemini CLI reads MCP server definitions from `~/.gemini/settings.json` and can load project guidance from `GEMINI.md`. Tolaria must support those conventions without silently rewriting global user settings or overwriting user-authored vault instructions.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria adds `~/.gemini/settings.json` to the explicit external AI setup path list. The existing MCP entry shape is reused: `mcpServers.tolaria` runs the packaged stdio server through Node.js, pins `VAULT_PATH` to the selected vault, and sets `WS_UI_PORT=9711`.
|
||||
|
||||
Vault guidance keeps `AGENTS.md` as the canonical shared source. `restore_vault_ai_guidance` can create or repair a managed `GEMINI.md` shim that imports `AGENTS.md`, while bootstrap and repair flows continue to seed only required Tolaria guidance (`AGENTS.md` and `CLAUDE.md`) plus type scaffolding. Custom `GEMINI.md` files are classified as custom and are not overwritten.
|
||||
|
||||
The setup dialog documents that Gemini CLI still needs its own install and sign-in. Tolaria does not store Gemini credentials or model-provider API keys.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Add Gemini to explicit MCP setup and optional guidance restore** (chosen): matches the existing consent boundary, preserves user settings, and gives Gemini the shared vault context.
|
||||
- **Generate `GEMINI.md` automatically for every vault**: simpler discovery, but turns an optional third-party compatibility file into startup side effect and dirties vaults for users who do not use Gemini.
|
||||
- **Document manual Gemini setup only**: avoids code changes, but leaves users to transpose paths and loses the active-vault safety already available for other MCP clients.
|
||||
- **Create a Gemini-specific MCP entry shape**: unnecessary because Gemini accepts the same `mcpServers` entry structure used by the existing Tolaria MCP server.
|
||||
|
||||
## Consequences
|
||||
|
||||
- External AI setup now writes/removes Tolaria's MCP entry in Claude, Gemini, Cursor, and generic MCP config files.
|
||||
- Gemini users can create a managed `GEMINI.md` shim without duplicating the canonical `AGENTS.md` guidance.
|
||||
- Existing vault bootstrap and repair remain non-invasive for users who do not use Gemini.
|
||||
- Native QA requires a local Gemini CLI install and authentication for an end-to-end Gemini prompt; otherwise the app can still verify the generated config and documentation paths.
|
||||
39
docs/adr/0092-vault-ai-agent-permission-modes.md
Normal file
39
docs/adr/0092-vault-ai-agent-permission-modes.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0092"
|
||||
title: "Vault-scoped AI agent permission modes"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0074 established explicit setup and least-privilege defaults for desktop AI tools. The in-app AI panel now supports multiple local CLI agents, and users need a clear per-vault way to choose whether an agent should stay in the narrow vault-safe profile or use broader local-work tools for that vault.
|
||||
|
||||
The mode must be visible at the point of use, must not mutate global CLI settings, and must not silently restore dangerous bypass flags. Existing transcripts should remain intact when the mode changes because a change applies to the next agent run, not to a process that is already streaming.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria stores an `ai_agent_permission_mode` per vault with values `safe` and `power_user`, defaulting missing or null values to `safe`, and passes that normalized mode through the AI panel stream request into each CLI adapter.**
|
||||
|
||||
The AI panel header displays the current mode and offers a compact Vault Safe / Power User control that is disabled while an agent run is active. Changing the mode preserves the transcript and inserts a local transcript marker.
|
||||
|
||||
Adapter mappings remain conservative:
|
||||
- Claude Code Safe keeps `acceptEdits`, strict Tolaria MCP config, and file/search/edit tools only; Power User adds Bash to the allowed tool list without using `--dangerously-skip-permissions`.
|
||||
- Codex keeps the active-vault `workspace-write` sandbox and `--ask-for-approval never` in both modes.
|
||||
- OpenCode uses transient `OPENCODE_CONFIG_CONTENT`; Safe denies bash and external directories, while Power User allows bash but still denies external directories.
|
||||
- Pi receives the mode on the adapter request path; both modes currently use the same transient MCP adapter config.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Per-vault Safe / Power User modes** (chosen): makes the permission surface explicit where the agent is used and preserves least-privilege defaults for each vault.
|
||||
- **Global app setting**: simpler storage, but a single toggle can over-apply a power-user profile to unrelated vaults.
|
||||
- **Dangerous bypass mode**: maximizes CLI freedom, but violates ADR-0074's least-privilege boundary and needs a separate explicit security decision.
|
||||
- **Adapter-specific UI switches**: exposes too much implementation detail and makes cross-agent behavior harder to reason about.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Vault config normalization owns the safe default for old vaults and malformed values.
|
||||
- Agent requests now carry a permission mode through frontend and Rust boundaries, so new adapters must choose an explicit mapping.
|
||||
- Power User is intentionally not equivalent across agents; where an adapter lacks a safe broader local-work switch, both modes may map to the same conservative behavior and must document that with tests.
|
||||
- Any future dangerous mode requires a new ADR and separate UI language.
|
||||
35
docs/adr/0093-shared-cli-agent-runtime-adapters.md
Normal file
35
docs/adr/0093-shared-cli-agent-runtime-adapters.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0093"
|
||||
title: "Shared CLI agent runtime adapters"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria supports Claude Code, Codex, OpenCode, and Pi as local CLI agents in the AI panel. Each agent has different command-line arguments, configuration shape, and JSON event schema, but the Rust backend had grown repeated runtime plumbing around those differences: request shapes, prompt wrapping, subprocess launch, stdout JSON reading, stderr capture, exit handling, done events, version probing, and Tolaria MCP server path resolution.
|
||||
|
||||
That duplication made small runtime fixes expensive because they had to be repeated across several adapter files. It also kept Codex-specific command and event mapping inside `ai_agents.rs`, making the top-level module both an orchestrator and a bespoke adapter.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses `cli_agent_runtime.rs` as the shared runtime scaffold for app-managed CLI agents, while `ai_agents.rs` only normalizes and dispatches requests to per-agent adapter modules.**
|
||||
|
||||
The shared scaffold owns the common agent request shape, system/user prompt wrapping, JSON-line process lifecycle, normalized error/done handling for `AiAgentStreamEvent` adapters, version probing, and Tolaria MCP server path resolution. Per-agent modules keep the provider-specific pieces: binary discovery candidates, command arguments, transient config shape, authentication error wording, and JSON event mapping.
|
||||
|
||||
Codex now lives in `codex_cli.rs`, matching the Claude, OpenCode, and Pi adapter boundary. `ai_agents.rs` remains the Tauri-facing orchestrator that chooses an adapter and maps Claude's legacy event enum into the normalized event stream.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Shared runtime scaffold with thin adapters** (chosen): reduces repeated process lifecycle code without hiding provider-specific command/config/event behavior.
|
||||
- **One trait object per agent**: more uniform on paper, but adds indirection without removing much current complexity.
|
||||
- **Leave each adapter self-contained**: keeps local readability for a single file, but new process, prompt, and MCP fixes continue to land in parallel.
|
||||
- **Fully generic event mapping**: over-abstracts the JSON schemas and makes provider-specific edge cases harder to test.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Runtime lifecycle fixes should usually start in `cli_agent_runtime.rs`.
|
||||
- New agent adapters should use the shared request/prompt/process helpers and keep only command, config, discovery, and event mapping local.
|
||||
- `ai_agents.rs` should not grow provider-specific runtime code again; it should normalize the frontend request, dispatch to an adapter, and map any legacy event shape.
|
||||
- The shared scaffold deliberately does not erase provider differences. Authentication messages, permission semantics, and transient config formats remain adapter-owned and must stay covered by adapter tests.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0094"
|
||||
title: "Gitignored content visibility as a command-boundary filter"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's vault scanner now indexes more of the real filesystem so Folder views, search, and reload flows can reflect what is actually in the vault. In Git-backed vaults, that includes generated, local-only, or machine-specific content that users intentionally hide through `.gitignore`.
|
||||
|
||||
Always showing Gitignored files makes Folder lists and search noisy, especially in vaults that contain exports, build artifacts, or personal local scratch files. But removing those files during scanning would make visibility dependent on cache shape, complicate toggling, and blur the distinction between "what exists in the vault" and "what this installation chooses to surface."
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps the vault scan and cache complete, then applies Gitignored-content visibility at the command boundary before entries, folders, or search results reach React.**
|
||||
|
||||
- `hide_gitignored_files` is an installation-local app setting and defaults to `true`.
|
||||
- Visibility checks use batched `git check-ignore --no-index --stdin` so Tolaria follows normal Git ignore and negation semantics as closely as practical.
|
||||
- `list_vault`, `reload_vault`, `list_vault_folders`, and keyword search all apply the same filter when the setting is enabled.
|
||||
- Toggling the setting reloads the current vault surfaces instead of rebuilding a different cache format.
|
||||
- If a vault has no `.gitignore`, or Gitignored visibility is turned off, Tolaria shows the full scanned result.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Complete scan/cache + boundary filter** (chosen): keeps the filesystem model authoritative, makes toggling cheap and consistent, and avoids cache divergence.
|
||||
- **Skip Gitignored content during scan/cache**: reduces later filtering work, but makes visibility part of the persisted cache shape and complicates instant toggling.
|
||||
- **Always show Gitignored content**: simplest implementation, but too noisy for real Git-backed vaults and undermines users' existing ignore rules.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Gitignored visibility is a per-installation comfort preference, not vault-authored shared metadata.
|
||||
- Search, folder lists, and note reloads stay aligned because they all consult the same boundary filter.
|
||||
- The cache can still support future visibility changes without a data migration.
|
||||
- Users can reveal ignored content again immediately by disabling the setting.
|
||||
- Future features that expose vault file lists should apply the same boundary filter unless they intentionally need raw filesystem output.
|
||||
35
docs/adr/0095-saved-view-order-field.md
Normal file
35
docs/adr/0095-saved-view-order-field.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0095"
|
||||
title: "Saved views use an explicit YAML order field"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Saved Views already persist as user-editable YAML files in the vault and sync through Git. Filename ordering was stable, but it forced users to rename files just to change sidebar order and gave Tolaria no durable way to support drag reordering, move actions, or keyboard-first ordering controls.
|
||||
|
||||
The ordering choice also needs to travel with the view definition itself. Saved Views are part of the vault's shared information architecture, not a machine-local preference.
|
||||
|
||||
## Decision
|
||||
|
||||
**Each Saved View may store an optional top-level `order` number in its YAML definition, and Tolaria sorts views by that value before falling back to filename.**
|
||||
|
||||
- Lower `order` values render earlier in the sidebar and other Saved View lists.
|
||||
- Views without `order` sort after ordered views and then fall back to filename ordering for stability.
|
||||
- Reordering actions rewrite affected view files with a dense sequence of order values instead of encoding position in filenames.
|
||||
- The same persisted order supports drag handles, explicit move buttons, and command-palette ordering actions.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Explicit `order` field in the view YAML** (chosen): portable, Git-syncable, easy to inspect by hand, and consistent with the existing file-first view model.
|
||||
- **Filename-based ordering only**: no schema change, but makes reordering clumsy and couples user-visible structure to file naming.
|
||||
- **App-local ordering state**: easy to prototype, but breaks cross-device consistency and separates ordering from the view artifact users already version.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Saved View ordering becomes part of the vault and syncs naturally through Git.
|
||||
- Existing views remain valid; unordered files keep a stable fallback sort until reordered.
|
||||
- Reordering can touch multiple view files in one action because Tolaria normalizes the sequence.
|
||||
- Future Saved View features should treat `order` as part of the shared YAML schema rather than introducing a parallel ordering store.
|
||||
37
docs/adr/0096-root-created-type-documents.md
Normal file
37
docs/adr/0096-root-created-type-documents.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0096"
|
||||
title: "Root-created type documents"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria identifies type definitions by markdown frontmatter (`type: Type`), not by filesystem location. Older documentation and UI creation flows still treated `type/` as the canonical destination for new type documents, with a compatibility fallback for vaults that already used `types/`.
|
||||
|
||||
That folder-based creation policy conflicted with the broader vault model: notes are scanned from all non-hidden folders, type identity comes from metadata, and root `type.md` / `note.md` definitions are already used by repair and bootstrap flows.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria creates new type documents at the vault root.**
|
||||
|
||||
- A type document is any markdown note with `type: Type` in frontmatter.
|
||||
- New UI-created type documents use `{vault}/{slug}.md`.
|
||||
- Existing type documents in `type/`, `types/`, or other scanned folders remain valid and continue to drive templates, icons, colors, visibility, sorting, and sidebar grouping.
|
||||
- Creation does not silently migrate or move existing type documents.
|
||||
- Root filename collisions are handled as file collisions; Tolaria must not overwrite an existing note when creating a type document.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Root-created type documents** (chosen): matches the metadata-first model, removes special casing from creation, and aligns new types with root-managed default type scaffolding.
|
||||
- **Canonical `type/` folder**: avoids root filename collisions, but makes path special even though type identity is already defined by frontmatter.
|
||||
- **Preserve existing folder convention dynamically**: minimizes change for plural `types/` vaults, but creates inconsistent behavior across vaults and leaves `types/` only partially supported.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Users can inspect and edit type documents as ordinary root notes by default.
|
||||
- Existing vaults with `type/` or `types/` type documents remain readable because vault scanning already includes non-hidden subdirectories.
|
||||
- If a root note with the same slug already exists, type creation fails with a collision message instead of writing into a fallback folder.
|
||||
- Legacy `type/` may remain hidden from the folder tree so old type documents do not duplicate the Types sidebar section.
|
||||
- Re-evaluate if users need a guided migration from folder-based type documents to root type documents.
|
||||
42
docs/adr/0097-gemini-cli-agent-adapter.md
Normal file
42
docs/adr/0097-gemini-cli-agent-adapter.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0097"
|
||||
title: "Gemini CLI agent adapter"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0091 added Gemini CLI to explicit external MCP setup, but Gemini was still absent from Tolaria's selectable app-managed AI agents. That left the AI panel able to generate Gemini-compatible MCP configuration while the actual agent picker, availability checks, install links, and streaming dispatch did not treat Gemini like Claude Code, Codex, OpenCode, or Pi.
|
||||
|
||||
Gemini CLI supports headless `--prompt` execution with JSON output, configurable approval modes, tool exclusion, and settings-file overrides through `GEMINI_CLI_SYSTEM_SETTINGS_PATH`. Those features are enough to launch Gemini from Tolaria without mutating the user's durable `~/.gemini/settings.json` during app-managed sessions.
|
||||
|
||||
## Decision
|
||||
|
||||
Tolaria adds Gemini CLI as a first-class `AiAgentId`. The frontend agent definitions, onboarding prompt, install links, default-agent normalization, status badge, command registry, settings persistence, and mock Tauri status payloads include `gemini`.
|
||||
|
||||
The desktop backend adds a Gemini adapter that:
|
||||
|
||||
- discovers `gemini` through the process path, login shell, and common local/toolchain install locations
|
||||
- runs `gemini --output-format json --approval-mode <mode> --prompt <prompt>` from the active vault
|
||||
- supplies Tolaria MCP through a temporary settings file referenced by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
|
||||
- uses Safe mode with `auto_edit`, an untrusted MCP entry, and `tools.exclude=["run_shell_command"]`
|
||||
- uses Power User mode with `yolo` and a trusted Tolaria MCP entry
|
||||
- maps Gemini JSON responses into Tolaria's existing AI panel stream events
|
||||
|
||||
The existing external MCP setup remains explicit and durable. The app-managed Gemini adapter uses transient settings so selecting Gemini in Tolaria does not rewrite the user's global Gemini config.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Add Gemini as a first-class app-managed agent** (chosen): matches the existing agent picker and onboarding UI, uses Gemini's headless JSON mode, and keeps MCP setup vault-scoped.
|
||||
- **Keep Gemini as external MCP setup only**: avoids another adapter, but keeps the interface inconsistent and requires users to leave Tolaria for a flow that other agents support in-panel.
|
||||
- **Write app-managed Gemini config into `~/.gemini/settings.json`**: reuses the external setup path, but would blur the consent boundary and risk overwriting user preferences during normal AI panel usage.
|
||||
- **Use interactive Gemini sessions**: could preserve richer CLI state, but does not fit Tolaria's current one-request stream lifecycle and would make cleanup/auth/error handling harder.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Gemini appears anywhere users can choose, install, or switch local AI agents.
|
||||
- End-to-end native Gemini QA requires the Gemini CLI to be installed and authenticated, but missing/auth failures now produce agent-specific guidance.
|
||||
- Safe and Power User behavior is limited by Gemini's own approval/tool semantics; if Gemini changes those names, the adapter tests and docs need updating.
|
||||
- The durable MCP setup path and optional `GEMINI.md` shim continue to serve external Gemini usage outside Tolaria's AI panel.
|
||||
30
docs/adr/0098-in-app-image-and-pdf-file-previews.md
Normal file
30
docs/adr/0098-in-app-image-and-pdf-file-previews.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0098"
|
||||
title: "In-app image and PDF previews for binary vault files"
|
||||
status: superseded
|
||||
date: 2026-04-29
|
||||
supersedes: "0086"
|
||||
superseded_by: "0110"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0086 introduced the `FilePreview` path for image binaries while keeping binary files as ordinary `VaultEntry` records. The same file-first model should now cover PDFs, because asset-heavy vaults often mix screenshots, diagrams, and document exports that users need to inspect without leaving Tolaria.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria previews supported image and PDF files in the editor pane while keeping them as ordinary binary vault files.**
|
||||
|
||||
- The scanner keeps the coarse `fileKind: "binary"` representation. Previewability stays a renderer concern inferred from the file extension in `src/utils/filePreview.ts`.
|
||||
- Supported images render with `<img>` and supported PDFs render with the webview PDF object renderer, both using Tauri asset URLs from `convertFileSrc`.
|
||||
- The Tauri CSP permits scoped asset URLs in `object-src` so PDF objects can load vault-backed files without broadening script, connect, or image policy.
|
||||
- PDF preview fallback content lives inside the PDF object so unsupported or failed renderers still expose an explicit "Open in default app" escape hatch.
|
||||
- Note-list rows for previewable images and PDFs remain clickable and carry file-specific indicators; unsupported binary rows stay muted and non-clickable.
|
||||
- `Escape` on the preview surface returns keyboard focus to the note list, matching the existing image-preview keyboard behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
- PDFs do not become notes and do not get Markdown editor semantics.
|
||||
- The asset preview surface can keep growing to additional safe binary formats without changing the vault scanner or persisted cache shape.
|
||||
- Broken PDFs may rely on the webview's own renderer failure state, but the surrounding Tolaria preview chrome still provides reveal, copy path, and default-app actions.
|
||||
37
docs/adr/0099-cumulative-vault-asset-scope.md
Normal file
37
docs/adr/0099-cumulative-vault-asset-scope.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0099"
|
||||
title: "Cumulative vault asset scope for previews"
|
||||
status: active
|
||||
date: 2026-04-29
|
||||
supersedes: "0074 asset-protocol runtime scoping"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0074 moved the desktop asset protocol away from broad filesystem access and toward runtime vault scoping. The implementation tried to keep only the active vault in scope by calling Tauri's `forbid_directory` for vault roots that were no longer active.
|
||||
|
||||
Tauri's filesystem scope treats forbidden paths as permanent precedence rules: a forbidden path is denied even if it is later allowed again. After a user switched away from a vault and back, image and PDF previews could keep producing `403 Forbidden` responses for valid vault files until the app restarted.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria accumulates Tauri asset protocol access for vault roots loaded during the current app session and never forbids a previously loaded vault root at runtime.**
|
||||
|
||||
- `sync_vault_asset_scope` adds the canonical vault root and requested vault root when they are missing from the runtime asset scope.
|
||||
- The runtime asset scope remains narrower than global filesystem access because only vault roots that Tolaria has loaded are added.
|
||||
- Command paths still enforce the active vault boundary through the Rust command layer before reads, writes, external opens, and attachment imports.
|
||||
- Asset scope revocation is deferred to process exit, because Tauri does not expose a safe runtime unallow operation for directories.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Cumulative runtime vault scope** (chosen): keeps previews reliable after vault switches while preserving vault-only access in the current process.
|
||||
- **Continue forbidding previous vaults**: appears stricter, but Tauri forbids are not reversible and valid previews fail after switching back.
|
||||
- **Allow all filesystem paths**: avoids preview failures but returns to the broad asset protocol access that ADR-0074 intentionally removed.
|
||||
- **Replace `convertFileSrc` with a custom protocol**: could support exact active-vault revocation, but it would be a larger cross-cutting migration for editor images, file previews, and PDF rendering.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Images and PDFs from any vault loaded in the current session can keep rendering after vault switches.
|
||||
- The app process, not each vault switch, is the revocation boundary for asset URL access.
|
||||
- Active-vault command validation remains the primary guard for mutations and default-app opens.
|
||||
- Re-evaluate this if Tauri adds a public runtime unallow operation for asset protocol directories.
|
||||
35
docs/adr/0100-synthetic-vault-root-folder-row.md
Normal file
35
docs/adr/0100-synthetic-vault-root-folder-row.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0100"
|
||||
title: "Synthetic vault-root row in folder navigation"
|
||||
status: active
|
||||
date: 2026-04-30
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0033 introduced subfolder scanning and a collapsible folder tree backed by `list_vault_folders`, but the sidebar still had no first-class way to select the vault root itself. That left root-level files outside the folder-navigation model and pushed the UI toward one-off handling for the opened vault path.
|
||||
|
||||
The new sidebar behavior needs to show root-level files when the user clicks the vault name, while preserving the existing folder rename/delete model for real folders only.
|
||||
|
||||
## Decision
|
||||
|
||||
**Represent the vault root in the sidebar as a synthetic frontend-owned folder row rather than as a mutable backend folder.**
|
||||
|
||||
- `FolderTree` wraps backend folder nodes in a root row with `path: ""` and `rootPath` set to the opened vault path.
|
||||
- `SidebarSelection` keeps using `kind: 'folder'`, but root selection is encoded as the empty folder path plus `rootPath` metadata.
|
||||
- Root-level file filtering is handled in note-list helpers as a dedicated root case instead of pretending the vault root is an ordinary folder.
|
||||
- Rename/delete remain available only for real folders; the vault root row is navigable, not mutable.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Synthetic vault-root row in the renderer — keeps `list_vault_folders` focused on real folders, avoids backend schema churn, and reuses the existing folder-selection mental model.
|
||||
- **Option B**: Add a pseudo-folder to backend folder results — would couple presentation-only root behavior to command data and blur the distinction between the vault itself and mutable folders.
|
||||
- **Option C**: Keep root files outside folder navigation entirely — simpler, but leaves the sidebar with an incomplete navigation model and special cases elsewhere in the UI.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Folder navigation now has a single model for root and nested folder browsing.
|
||||
- Backend folder APIs stay unchanged: they describe actual folders, not UI-only rows.
|
||||
- Selection handling must treat `path: ""` as the vault-root case and use `rootPath` when computing direct-root file membership.
|
||||
- Re-evaluate if folder actions ever need to operate on the vault root itself, because that would likely require a separate command model instead of extending the synthetic row.
|
||||
35
docs/adr/0101-categorical-product-analytics-events.md
Normal file
35
docs/adr/0101-categorical-product-analytics-events.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0101"
|
||||
title: "Categorical product analytics events"
|
||||
status: active
|
||||
date: 2026-04-30
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0016 established opt-in PostHog analytics and Sentry crash reporting, but new feature telemetry now spans file previews, inline image lightbox opens, AI agent session lifecycle events, and All Notes visibility toggles. Instrumenting those flows ad hoc would make it easy to leak vault-specific data such as note paths, filenames, prompt text, or image URLs.
|
||||
|
||||
Tolaria needs richer product telemetry for feature behavior while preserving the privacy bar expected from a local-first notes app.
|
||||
|
||||
## Decision
|
||||
|
||||
**Route product analytics through dedicated helper functions that emit only coarse categorical metadata.**
|
||||
|
||||
- Product events live behind `src/lib/productAnalytics.ts` instead of scattering raw `trackEvent()` payloads across feature code.
|
||||
- Allowed payloads are constrained to categories and counts such as preview kind, action kind, AI agent id, permission mode, response/tool counts, status, and All Notes visibility category/enabled state.
|
||||
- Product events must not include vault content, note titles, file paths, filenames, image URLs, prompt text, or other user-authored data.
|
||||
- When a feature needs telemetry, it should add or extend a typed helper rather than sending free-form payloads inline.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Typed categorical wrappers for product events — preserves useful product signals while keeping privacy constraints explicit and reviewable in one place.
|
||||
- **Option B**: Let each feature call `trackEvent()` directly with whatever fields seem useful — faster short term, but inconsistent and too easy to let sensitive data leak into analytics.
|
||||
- **Option C**: Avoid new product events entirely — safest from a privacy standpoint, but leaves the team blind to whether preview, AI, and list-visibility features are actually being used or failing.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Telemetry reviews become easier because the allowed product-event surface is centralized.
|
||||
- Future product analytics work should start by defining categorical event helpers, not by attaching raw note/file context.
|
||||
- Some debugging detail is intentionally sacrificed; deep diagnosis should rely on consented crash reporting and local reproduction rather than richer analytics payloads.
|
||||
- Re-evaluate if Tolaria ever needs a broader telemetry taxonomy or stronger compile-time guarantees around event schemas.
|
||||
29
docs/adr/0102-low-end-safe-autosave-idle-window.md
Normal file
29
docs/adr/0102-low-end-safe-autosave-idle-window.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0102"
|
||||
title: "Low-end-safe autosave idle window"
|
||||
status: active
|
||||
date: 2026-04-30
|
||||
supersedes: "0015"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0015 chose a 500ms autosave debounce so normal edits would persist quickly without writing on every keystroke. GitHub issue #443 showed that this window is too aggressive on very weak Windows CPUs: if typing intervals exceed 500ms or a save takes long enough to overlap continued typing, Tolaria can start disk and derived-state work while the user is still entering text.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria autosaves after a 1.5s idle window and treats stale in-flight autosaves as obsolete when newer content arrives before they resolve.** Manual saves, note switches, raw-mode entry, and destructive actions still flush pending editor content immediately.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **Option A — 1.5s idle window plus stale-save protection** (chosen): reduces mid-typing saves on slow CPUs while keeping ordinary autosave behavior fast enough for reliability. It also prevents an older slow save from clearing or repainting over newer pending text.
|
||||
- **Option B — Keep 500ms and only fix stale saves**: preserves the previous timing but still triggers repeated saves for slower typists and weaker machines.
|
||||
- **Option C — Save only on blur or navigation**: minimizes background work but increases crash-loss risk during longer writing sessions.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Autosave is less likely to compete with active typing on low-end Windows hardware.
|
||||
- The unsaved window grows from roughly 500ms to roughly 1.5s after Tolaria receives the latest content change.
|
||||
- Explicit flush paths remain immediate, so navigation, manual save, raw-mode transitions, and destructive actions still preserve pending edits before proceeding.
|
||||
- Future changes to autosave timing should keep weak-CPU responsiveness and stale in-flight save behavior in the same test surface.
|
||||
32
docs/adr/0103-adapter-specific-ai-permission-semantics.md
Normal file
32
docs/adr/0103-adapter-specific-ai-permission-semantics.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0103"
|
||||
title: "Adapter-specific AI permission semantics"
|
||||
status: active
|
||||
date: 2026-04-30
|
||||
supersedes: "0092"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0092 introduced per-vault Vault Safe / Power User modes, but the first implementation left too much room for adapter drift. Some agents can directly deny or allow Bash, some expose only a sandbox/approval profile, and Pi currently has no narrower app-managed switch beyond Tolaria's transient MCP configuration. The shared UI still needs a consistent product contract: Vault Safe must not encourage shell execution, while Power User should keep shell execution available across repeated agent turns where the selected adapter supports it.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria treats the permission mode as a product contract first and maps it conservatively per adapter.**
|
||||
|
||||
- Shared AI system prompts are mode-aware on every turn, including turns with note context snapshots.
|
||||
- Vault Safe tells agents not to use or advertise shell, terminal, Bash, script execution, git, or command-line tools.
|
||||
- Power User tells shell-capable agents that local shell commands are available for the active vault and should remain scoped to that vault.
|
||||
- Claude Code Safe excludes Bash; Power User includes and pre-approves Bash without dangerous bypass flags.
|
||||
- Codex Safe uses the CLI's read-only sandbox plus untrusted approval policy; Power User uses workspace-write plus never-ask approval so shell-capable Codex turns remain low-friction across the session.
|
||||
- OpenCode Safe denies bash and external directories; Power User allows bash while still denying external directories.
|
||||
- Pi keeps the same conservative transient MCP config in both modes until the Pi CLI exposes a reliable app-managed shell permission switch. The prompt must not promise shell for Pi Power User.
|
||||
- Gemini Safe excludes `run_shell_command`; Gemini Power User intentionally uses `yolo` with trusted transient Tolaria MCP settings.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Mode behavior is no longer described solely by generic UI copy; adapter docs and tests define the exact mapping.
|
||||
- Codex Vault Safe remains a best-effort safe profile rather than a true built-in-tools-off mode, because Codex CLI currently exposes sandbox and approval controls but not a dedicated switch to remove shell tooling while preserving MCP.
|
||||
- Future adapters must either implement both modes explicitly or document that Power User maps to the same conservative behavior.
|
||||
- If Tolaria adds a stronger warning or dangerous mode later, it needs a separate ADR and UI language.
|
||||
48
docs/adr/0104-tauri-frontend-readiness-watchdog.md
Normal file
48
docs/adr/0104-tauri-frontend-readiness-watchdog.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0104"
|
||||
title: "Tauri frontend readiness watchdog"
|
||||
status: active
|
||||
date: 2026-05-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria already keeps heavy filesystem and subprocess work off the Tauri window-creation path, but that alone does not protect against a different startup failure mode: the desktop WebView can render the static HTML shell while the React app never becomes interactive.
|
||||
|
||||
On macOS this showed up as an inert window that looked launched but never finished mounting the real app. The failure boundary is cross-layer:
|
||||
|
||||
- `index.html` can paint before React commits
|
||||
- React root errors can happen before the app reports itself ready
|
||||
- a plain reload is acceptable as a one-time recovery, but an automatic reload loop is not
|
||||
- browser/mock runs should not inherit desktop-only recovery behavior
|
||||
|
||||
Tolaria needs a startup contract that distinguishes “HTML painted” from “frontend actually became interactive”, and a bounded recovery path when that contract is not satisfied.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria uses a Tauri-only frontend-readiness watchdog that reloads the WebView at most once if React never reports startup readiness.**
|
||||
|
||||
Concretely:
|
||||
|
||||
- `index.html` installs a Tauri-only startup timer before React loads
|
||||
- React dispatches a readiness signal from a mounted effect after the app shell commits
|
||||
- if readiness never arrives before the timeout, the WebView reloads once
|
||||
- the same one-shot reload path is available to React root error handling before readiness is marked
|
||||
- `sessionStorage` tracks whether the startup reload was already attempted so Tolaria does not loop forever
|
||||
- browser/mock environments keep using ordinary browser clipboard/storage behavior and do not enable this desktop startup recovery path
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Tauri-only readiness watchdog with one-shot reload** (chosen): directly addresses the inert-startup failure mode, keeps recovery local to the frontend, and avoids permanent reload loops. Cost: startup now depends on a small cross-layer contract between HTML bootstrap and React.
|
||||
- **Do nothing and rely on manual relaunch**: simplest implementation, but leaves users stranded in a broken-looking app state with no automatic recovery.
|
||||
- **Reload on any React root error without a readiness gate**: more aggressive, but too noisy; post-startup runtime errors should not trigger surprise reloads.
|
||||
- **Move recovery entirely into native Rust window/bootstrap logic**: possible, but the failure signal lives in the frontend lifecycle, so native code would still need a readiness handshake.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tolaria now distinguishes successful frontend startup from merely rendering the HTML shell.
|
||||
- Desktop startup recovery is bounded to a single retry per session, reducing the chance of trapping users in reload loops.
|
||||
- `index.html`, `src/main.tsx`, and `src/utils/frontendReady.ts` form a shared startup contract that future bootstrap refactors must preserve.
|
||||
- Any future change that delays app-shell mount beyond the watchdog timeout must re-evaluate the timeout and readiness trigger.
|
||||
- If a startup failure persists after one retry, Tolaria still surfaces the broken state instead of hiding a deeper bug behind repeated reloads.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0105"
|
||||
title: "Editor correctness and responsiveness contract"
|
||||
status: active
|
||||
date: 2026-05-01
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are durable Markdown files on disk, but the rich editor renders them through BlockNote blocks. Large notes exposed a tempting optimization: show a fast Markdown preview first, then hydrate BlockNote later. In practice, that creates two renderers for the same document, visible flicker, delayed click-time lag, and more places for stale async work to race with the currently selected note.
|
||||
|
||||
The editor must optimize for the product priorities in this order:
|
||||
|
||||
1. no crashes
|
||||
2. no stale content, race-condition overwrites, or lost edits
|
||||
3. responsive typing and cursor movement
|
||||
4. fast note-list-to-editor loading
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria keeps a single direct editor surface for Markdown notes and treats editor content swaps as generation-checked, source-content-checked operations.** Fast loading may use raw file-content prefetching and a bounded parsed-block cache, but it must not show a separate preview that later swaps into the editor. Parsed BlockNote blocks are reusable only when their source Markdown exactly matches the content being opened, and background parsing must run only after recent typing/navigation has gone idle.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Single direct editor surface with guarded swaps plus bounded caches** (chosen): preserves one visual representation of the document, rejects stale async parse results, validates or identity-checks cached disk content before opening, and keeps typing work debounced. Cons: very large notes can still wait on BlockNote conversion when they were not warmed.
|
||||
- **Fast Markdown preview followed by hidden BlockNote hydration**: improves first paint but creates flicker, delayed edit-time stalls, and duplicated rendering semantics.
|
||||
- **Unbounded or eager background BlockNote parsing for likely next notes**: can make some opens faster, but competes with typing/navigation and introduces stale parse-result hazards unless heavily scheduled, bounded, and invalidated.
|
||||
- **Always raw mode for large notes**: strongest responsiveness for huge files, but changes the editing experience abruptly and should be an explicit fallback rather than the default.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Async editor work must prove it still matches both the latest swap generation and the latest tab source content before touching BlockNote.
|
||||
- Cached raw note content must be validated against disk before it is shown unless it carries the same `modifiedAt` and `fileSize` identity as the current `VaultEntry`, or the content was just authored by Tolaria in the current process.
|
||||
- Cached parsed BlockNote blocks must be keyed by vault, path, and exact source content, cloned on read/write, and bounded by entry count plus source byte budget.
|
||||
- Background parsed-block warming is allowed only for likely next large Markdown notes after a foreground idle window; active typing, raw mode, and editor mount state must defer it.
|
||||
- Dirty local editor content remains authoritative. External filesystem refreshes may replace clean notes, but must not overwrite unsaved local edits.
|
||||
- Per-keystroke editor work must stay minimal. Serialization, metadata derivation, autosave, and cache updates should be debounced, coalesced, or scheduled away from active typing.
|
||||
- Future large-note optimizations should target true progressive/chunked conversion or explicit raw/read-only fallback states, not a visually different preview that morphs into BlockNote.
|
||||
31
docs/adr/0106-shared-app-command-manifest.md
Normal file
31
docs/adr/0106-shared-app-command-manifest.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0106"
|
||||
title: "Shared app command manifest"
|
||||
status: active
|
||||
date: 2026-05-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria command metadata was split across several runtime surfaces: TypeScript owned shortcut lookup and command-palette shortcut display, Rust owned native menu IDs, labels, accelerators, aliases, and enablement groups, and the Linux titlebar fallback menu duplicated another command list. Adding or changing a command required carefully editing multiple files that could drift while still compiling.
|
||||
|
||||
The existing renderer-first shortcut model and native-menu dedupe remain correct, but they need a single source for metadata that must be identical across those surfaces.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria stores cross-runtime app command metadata in `src/shared/appCommandManifest.json`, and both the renderer and Tauri native menu derive their command/menu IDs, accelerators, menu labels, menu aliases, enablement groups, and deterministic QA metadata from it.** Context-sensitive command-palette builders still own availability and execution callbacks, and OS-native menu entries remain local to the native menu implementation.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Shared JSON manifest included by TypeScript and Rust** (chosen): works in both runtimes without code generation, keeps menu metadata reviewable, and lets tests validate drift directly.
|
||||
- **Generate TypeScript and Rust constants from a schema**: gives stronger compile-time types but adds a build step and a generated-file maintenance burden for a small manifest.
|
||||
- **Keep duplicated constants with more tests**: reduces immediate refactor scope, but still forces every command change through parallel manual edits.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New app commands that appear in native menus or shortcut QA must be added to `src/shared/appCommandManifest.json`.
|
||||
- `appCommandCatalog.ts` is responsible for turning the manifest into typed renderer helpers such as `APP_COMMAND_IDS`, shortcut lookup maps, Linux menu sections, and deterministic QA definitions.
|
||||
- `src-tauri/src/menu.rs` includes the same manifest JSON, builds custom menu items from it, maps overridden menu item IDs such as `file-quick-open-alias` back to their primary command IDs, and resolves state-dependent enablement groups from manifest entries.
|
||||
- Platform-native menu items such as Undo, Redo, Copy, Paste, Select All, Services, Quit, and Window controls stay in Rust because they are OS affordances, not Tolaria app commands.
|
||||
- Command-palette builders continue to own dynamic labels, filtering, enabled state, and callbacks where those depend on current app state.
|
||||
43
docs/adr/0107-markdown-durable-tldraw-whiteboards.md
Normal file
43
docs/adr/0107-markdown-durable-tldraw-whiteboards.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0107"
|
||||
title: "Markdown-durable tldraw whiteboards in notes"
|
||||
status: active
|
||||
date: 2026-05-03
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria notes are durable Markdown files, while whiteboard editing needs an interactive canvas with structured shape data. tldraw provides a mature React whiteboard runtime and exposes snapshot APIs that can persist the document without using browser-local `persistenceKey` storage.
|
||||
|
||||
The storage decision needs to preserve Tolaria's filesystem source-of-truth rule: deleting local caches must not lose a board, raw mode must expose the canonical source, and Git should track the board together with the surrounding note.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria will support whiteboards as Markdown-durable fenced `tldraw` blocks backed by tldraw document snapshots.**
|
||||
|
||||
The implementation:
|
||||
|
||||
- Converts fenced `tldraw` blocks into temporary placeholders before BlockNote parses Markdown.
|
||||
- Replaces those placeholders with `tldrawBlock` schema blocks that store a stable board id and tldraw document snapshot JSON.
|
||||
- Renders each block with the `tldraw` package inside the rich editor.
|
||||
- Debounces tldraw document changes into BlockNote block props so Tolaria's normal autosave writes the snapshot into the `.md` file.
|
||||
- Serializes `tldrawBlock` nodes back to fenced Markdown before save, raw-mode entry, and editor-position snapshots.
|
||||
- Adds a `/whiteboard` slash command that inserts the same block format.
|
||||
|
||||
Session state such as camera position, selected shapes, and selected tools is not persisted into the note. Preview images are intentionally omitted from the initial design; they may be introduced later as derived cache artifacts for note lists, search results, or graph cards.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Markdown fenced block with tldraw document snapshot** (chosen): keeps the board in the note file, matches Tolaria's Mermaid/math round-trip model, and works for both full whiteboard notes and embedded boards inside larger notes.
|
||||
- **tldraw `persistenceKey` / IndexedDB**: simplest app integration, but violates the vault-as-source-of-truth rule and would lose boards when browser storage is cleared.
|
||||
- **Separate `.tldr` files embedded from Markdown**: keeps JSON out of prose notes, but fragments note ownership, makes embedded boards harder to move with their parent note, and complicates Git history for small board edits.
|
||||
- **Preview-image-first storage**: useful for thumbnails, but not editable source. It would make the PNG an attractive but stale source of truth.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `src/utils/tldrawMarkdown.ts` is the canonical parser/serializer bridge for whiteboard blocks.
|
||||
- `src/components/TldrawWhiteboard.tsx` owns the tldraw runtime integration and only persists document snapshots.
|
||||
- Raw mode remains a direct source editor for the fenced JSON.
|
||||
- Embedded whiteboards and future full-note whiteboard templates share the same storage format.
|
||||
- Asset support is deferred; when added, asset bytes should live in vault-relative attachment paths referenced from the tldraw asset records.
|
||||
32
docs/adr/0107-pointer-owned-editor-block-reordering.md
Normal file
32
docs/adr/0107-pointer-owned-editor-block-reordering.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0107"
|
||||
title: "Pointer-owned editor block reordering"
|
||||
status: active
|
||||
date: 2026-05-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria uses BlockNote for rich Markdown editing, Tauri for native desktop file drops, and custom editor drop handlers for images and wikilinks. BlockNote's default block drag path depends on HTML5 drag events and `DataTransfer`. On macOS inside the Tauri webview, that makes block reordering fragile because the same browser-level drag system also carries native file/image drops into the editor.
|
||||
|
||||
Regressions tended to oscillate: fixes that restored block dragging could break image drops, and fixes that protected native drops could make block reordering fail or lose visual feedback. The drag handle also needs editor-specific affordances: a moving block preview, an insertion separator, stale-block protection, and typography-aware positioning for the side-menu controls.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria owns editor block reordering as a pointer gesture that directly moves BlockNote blocks, and leaves HTML5/native drag data paths for file, image, and external drops.** The drag side menu is responsible for resolving live BlockNote blocks, computing pointer hit targets, rendering drag affordances, and aligning the hover controls to the rendered text range of the hovered block.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Pointer-owned block reordering** (chosen): separates internal block moves from native drop payloads, works without `DataTransfer`, gives Tolaria deterministic visual affordances, and can be tested with Playwright pointer/mouse actions. Cons: Tolaria now owns a small amount of hit-testing, block-move, and affordance code around BlockNote.
|
||||
- **Continue using BlockNote's HTML5 drag handler**: keeps less local code, but ties internal block moves to the same unstable drag channel used by native file drops in Tauri.
|
||||
- **Patch native file drops around BlockNote drag events**: might repair individual regressions, but preserves the root coupling between editor-internal reordering and external drag payload handling.
|
||||
- **Disable block dragging on macOS/Tauri**: avoids the conflict, but removes an important editing workflow.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Internal block reordering must not depend on `DataTransfer` or `draggable=true`.
|
||||
- File/image/wikilink drop behavior remains owned by the existing editor drop abstractions and native Tauri file-drop bridge.
|
||||
- Block reordering tests should use pointer or mouse movement in Playwright, and should assert the moving preview and insertion separator while the drag is in progress.
|
||||
- The side menu should align to measured rendered content rather than heading-specific pixel offsets, so future theme font-size and line-height changes do not need drag-control retuning.
|
||||
- Changes to BlockNote DOM structure around `.bn-block-content`, `.bn-inline-content`, `.bn-side-menu`, or block container IDs require rechecking the pointer hit-testing and side-menu alignment tests.
|
||||
30
docs/adr/0108-direct-model-ai-targets.md
Normal file
30
docs/adr/0108-direct-model-ai-targets.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0108"
|
||||
title: "Direct model AI targets alongside coding agents"
|
||||
status: active
|
||||
date: 2026-05-03
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria's AI panel originally targeted desktop coding-agent CLIs only. That works well for tool-capable vault editing, but it excludes users who run local model servers, users who prefer OpenAI or Anthropic APIs, and future mobile builds where desktop subprocesses cannot run.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria models AI selection as an AI target.** Targets can be desktop coding agents or direct model endpoints. Coding agents keep the existing Safe / Power User permission modes and tool access. Direct model targets run in Chat mode: they receive note context and conversation history, but they do not get vault-write tools or shell access.
|
||||
|
||||
Direct model provider metadata is stored in app settings. Provider API secrets are not stored in settings; hosted providers can either save a key in Tolaria's local app-data secrets file or read a key from a named environment variable. The local secrets file is outside the vault, outside project worktrees, and written with owner-only file permissions on Unix platforms. Local providers such as Ollama and LM Studio can run without a key.
|
||||
|
||||
## Options Considered
|
||||
|
||||
- **AI target abstraction** (chosen): supports agents, local models, hosted APIs, and mobile-compatible model runtimes without pretending all AI backends have the same capabilities.
|
||||
- **Treat custom providers as OpenCode configuration only**: lower implementation cost on desktop, but does not help mobile and keeps API users dependent on a coding-agent install.
|
||||
- **Direct API runtime with write tools immediately**: powerful, but would require Tolaria-owned tool loops, confirmations, retries, and safety semantics before the basic chat value is proven.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Settings owns durable provider setup and default target selection.
|
||||
- The status bar becomes a quick target switcher across agents and configured model targets.
|
||||
- The AI panel explains capability differences: agents show Safe / Power User; direct model targets show Chat mode.
|
||||
- Future work can migrate local secrets to OS keychain storage and add read/write tool loops without changing the top-level target model.
|
||||
25
docs/adr/0108-sanitized-rendered-markup-and-safe-regex.md
Normal file
25
docs/adr/0108-sanitized-rendered-markup-and-safe-regex.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 0108. Sanitized Rendered Markup and Safe User Regex
|
||||
|
||||
Date: 2026-05-03
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Tolaria renders generated SVG/HTML from trusted libraries such as Mermaid and KaTeX, and it allows users to opt into regex matching in filters and editor find/replace. Codacy SRM flagged the raw markup insertion and direct regex construction as Critical XSS/DoS risks.
|
||||
|
||||
## Decision
|
||||
|
||||
Add direct runtime dependencies on `dompurify` and `safe-regex2`.
|
||||
|
||||
Rendered Mermaid SVG and KaTeX HTML must be sanitized before insertion and mounted through DOM nodes rather than React `dangerouslySetInnerHTML`. User-provided regex sources must be length-bounded and checked with `safe-regex2` before compilation.
|
||||
|
||||
SCA-reported vulnerable transitive dependencies are pinned through package-manager overrides so Codacy resolves patched floors until upstream dependencies adopt them naturally. This includes `protobufjs`, the MCP SDK web-server stack, and Vite's parser/build transitive stack. Rust lockfile-only updates keep OpenSSL, rustls-webpki, and tar on patched versions without changing public Tauri APIs.
|
||||
|
||||
## Consequences
|
||||
|
||||
Markup rendering now has an explicit sanitizer boundary that is shared by Mermaid and math rendering. User regex features remain available, but unsafe or overly large expressions fail validation instead of running in the UI thread.
|
||||
|
||||
The overrides should be removed once the dependency graph no longer pulls the vulnerable versions.
|
||||
32
docs/adr/0109-debounced-worker-derived-editor-indexes.md
Normal file
32
docs/adr/0109-debounced-worker-derived-editor-indexes.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0109"
|
||||
title: "Debounced worker-derived editor indexes"
|
||||
status: active
|
||||
date: 2026-05-04
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Right side panels can need derived indexes of the active note, such as the Table of Contents hierarchy. These indexes are useful while editing, but rebuilding them synchronously during note opening or on every keystroke competes with the editor's main-thread work and violates ADR-0105's responsiveness contract.
|
||||
|
||||
The Table of Contents also needs live BlockNote block IDs for navigation, while the fastest and most stable source for the outline itself is the active note's Markdown content. Binding the outline rebuild directly to BlockNote document mutations makes typing and note swaps more expensive than necessary.
|
||||
|
||||
## Decision
|
||||
|
||||
**Derived editor indexes that are not required for the editor surface itself must be lazy, debounced, and built off the main thread when they can be derived from Markdown.** The Table of Contents does not build while its panel is closed; once opened, it uses a Web Worker to build its Markdown-derived H1/H2/H3 tree after a debounce, while live BlockNote block IDs are resolved only at click time for navigation.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Lazy debounced Web Worker for Markdown-derived indexes** (chosen): avoids any TOC work while the panel is closed, keeps outline parsing away from typing and note-opening work once opened, cancels stale panel updates, and lets the rendered editor remain the only editor surface. Cons: adds a small worker/client path and a title-only interim state.
|
||||
- **Main-thread deferred rebuild with `setTimeout`**: avoids blocking the first render, but still runs on the UI thread and can still rebuild too often during active edits.
|
||||
- **Synchronous rebuild from the BlockNote document**: simplest and gives immediate block IDs, but makes every BlockNote document update a potential side-panel rebuild.
|
||||
- **Never update the TOC while editing**: safest for typing performance, but stale outlines make the panel misleading for active authoring.
|
||||
|
||||
## Consequences
|
||||
|
||||
- TOC tree state is driven by note identity plus debounced Markdown content, not by BlockNote document churn.
|
||||
- Closing the TOC panel unmounts the panel and cancels pending debounce callbacks; no worker request is scheduled while the panel is closed.
|
||||
- The TOC may briefly show only the note title after a note switch or edit burst; the full tree appears when the debounced worker result returns.
|
||||
- Navigation remains tied to the live editor: block IDs are resolved from the current BlockNote document at click time and scrolled/focused then.
|
||||
- Future derived side-panel indexes should follow the same pattern when they parse or scan note content and are not needed to render the editor itself.
|
||||
43
docs/adr/0110-in-app-media-and-pdf-file-previews.md
Normal file
43
docs/adr/0110-in-app-media-and-pdf-file-previews.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0110"
|
||||
title: "In-app media and PDF previews for binary vault files"
|
||||
status: active
|
||||
date: 2026-05-05
|
||||
supersedes: "0098"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0098 extended Tolaria's file-first preview model from images to PDFs while keeping binary files as ordinary `VaultEntry` records. In practice, vaults also carry voice notes, interview recordings, screen captures, and short clips that users need to inspect in context without round-tripping through another app.
|
||||
|
||||
The existing binary preview architecture already had the important constraints in place:
|
||||
|
||||
- previewability should stay a renderer concern inferred from filename extension rather than a persisted schema field
|
||||
- preview access should stay inside Tauri's scoped asset protocol instead of broad filesystem reads
|
||||
- external-open actions must still re-enter the active-vault command boundary before delegating to the OS
|
||||
|
||||
Audio and video support should extend that same model rather than introducing a separate asset or media subsystem.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria previews supported image, audio, video, and PDF files in the editor pane while keeping them as ordinary binary vault files.**
|
||||
|
||||
- The scanner keeps the coarse `fileKind: "binary"` representation; `src/utils/filePreview.ts` infers preview support from safe extension allow-lists.
|
||||
- `FilePreview` remains the single renderer-owned preview surface for supported binary files.
|
||||
- Images continue to render through `<img>`, PDFs through the webview PDF object renderer, and audio/video through native HTML media controls, all backed by Tauri asset URLs from `convertFileSrc`.
|
||||
- The Tauri CSP must allow scoped asset URLs in `media-src` for audio/video and in `object-src` for PDFs without broadening script or network permissions.
|
||||
- Note-list rows for previewable media stay clickable and use file-specific affordances; unsupported binaries remain ordinary files with explicit fallback/open-external paths.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Extend the existing FilePreview model to media** (chosen): keeps one binary-preview surface, reuses scoped asset access, and avoids new persisted file categories. Cons: native media controls are intentionally minimal.
|
||||
- **Open audio and video only in the default app**: simpler implementation, but breaks in-context review for media-heavy vaults.
|
||||
- **Introduce dedicated persisted media file kinds or a separate media library**: could support richer metadata later, but adds schema and scanner complexity for files that should remain normal vault entries.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Audio and video do not become notes and do not get special persistence semantics.
|
||||
- Tolaria's binary preview surface now covers the common safe media formats without changing cache shape, scanner output, or the filesystem-first model.
|
||||
- Scoped runtime asset access and active-vault command validation remain the security boundary for binary previews and external-open actions.
|
||||
- Re-evaluate this decision if Tolaria later needs editing, waveform/timeline tooling, subtitles, or transcoding, because those would justify a richer media-specific subsystem.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0111"
|
||||
title: "Path-aware external vault refresh with focused-editor preservation"
|
||||
status: active
|
||||
date: 2026-05-05
|
||||
supersedes: "0071"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0071 established a shared reconciliation path for external vault mutations so git pulls, AI-agent writes, and other non-local edits would reload vault-derived state and protect unsaved local changes. That policy was directionally right, but the original "reopen the clean active note" rule was too broad once Tolaria added a native filesystem watcher and more editor-mounted integrations.
|
||||
|
||||
Two problems emerged:
|
||||
|
||||
- unrelated external updates could remount a clean active editor even when the active file itself did not change
|
||||
- remounting while focus was inside the rich or raw editor surface could drop cursor/focus state and disrupt native input flows even when the vault refresh itself was otherwise safe
|
||||
|
||||
Tolaria still needs refreshed entries, folders, views, backlinks, and other derived state after external writes. But it should only pay the cost of an active-editor remount when the changed-path batch actually requires one and the user is not actively focused inside the editor.
|
||||
|
||||
## Decision
|
||||
|
||||
**External vault refreshes now reload shared vault-derived state eagerly, but only remount the active editor when the active file itself changed and the editor is clean and unfocused.**
|
||||
|
||||
The shared `refreshPulledVaultState()` path now 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 focus is currently inside the rich or raw editor surface, keep that editor mounted even for otherwise clean notes.
|
||||
6. If the active file disappeared, close the tab instead of leaving a stale editor behind.
|
||||
7. Only close and reopen the active tab when the changed-path batch includes that active file and the previous guards did not apply.
|
||||
|
||||
Git pulls, AI-agent refresh callbacks, and filesystem-watcher batches should continue to converge through this single reconciliation helper instead of inventing separate reload policies.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Path-aware refresh with focused-editor preservation** (chosen): keeps derived vault state fresh while avoiding unnecessary remounts and focus loss. Cons: a focused clean editor can temporarily lag on-disk content until a later safe remount.
|
||||
- **Always reopen the clean active note after every external refresh**: strongest immediate convergence, but causes visible churn and drops editor focus for unrelated changes.
|
||||
- **Skip shared reloads whenever the editor is focused**: preserves focus, but leaves folders, views, backlinks, and other derived state stale.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Unrelated external vault updates no longer remount the active editor just because the note is clean.
|
||||
- Focused rich/raw editor sessions preserve cursor and native input state across watcher- or agent-driven vault refreshes.
|
||||
- The changed-path batch is now part of the external-refresh contract; callers should pass the best available file list instead of treating all refreshes as full active-note invalidations.
|
||||
- A focused clean editor may intentionally continue showing pre-refresh content until a later safe reopen, trading immediate active-note convergence for editing continuity.
|
||||
- `refreshPulledVaultState()` remains the single place to evolve external-refresh policy; future features should extend that helper rather than layering ad hoc editor reload behavior.
|
||||
41
docs/adr/0112-system-theme-mode.md
Normal file
41
docs/adr/0112-system-theme-mode.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0112"
|
||||
title: "System theme mode"
|
||||
status: active
|
||||
date: 2026-05-05
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0081 introduced Tolaria's internal app-owned light and dark theme runtime and deliberately deferred system-follow mode. That kept the first dark-mode release small, but users now need Tolaria to match the operating system appearance automatically, including scheduled macOS light/dark changes.
|
||||
|
||||
The previous constraints still apply: themes are app-owned, not vault-authored; the renderer must avoid startup flashes; shadcn/ui, Tailwind variables, editor chrome, and secondary windows must keep sharing the same resolved light/dark contract.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria now treats `system` as a persisted theme preference that resolves to the current OS light/dark appearance at runtime.**
|
||||
|
||||
The selected preference can be `light`, `dark`, or `system`:
|
||||
|
||||
1. `settings.theme_mode` remains the source of truth for the installation-local preference.
|
||||
2. The localStorage mirror stores the selected preference, including `system`, so the `index.html` prepaint script can resolve the correct appearance before React mounts.
|
||||
3. `data-theme` and the shadcn `.dark` class always receive the resolved app theme, `light` or `dark`; they never receive `system`.
|
||||
4. When `system` is selected, the renderer subscribes to `prefers-color-scheme` changes and reapplies the resolved theme without reopening the app.
|
||||
5. Explicit `light` and `dark` choices remain overrides and do not follow OS changes.
|
||||
|
||||
Command-palette theme actions and the Settings panel both save the same preference path. Product analytics record preference changes with the selected mode only, without sending vault or note content.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Persist `system` and resolve it into the existing light/dark runtime** (chosen): keeps ADR-0081's small app-owned theme surface while adding OS-follow behavior.
|
||||
- **Store the resolved OS theme in settings**: avoids a third stored value, but silently converts System users into explicit Light/Dark users after every save.
|
||||
- **Set `data-theme="system"` and branch in CSS**: would require every theme consumer to understand a third state and would break existing Tailwind/shadcn dark-mode assumptions.
|
||||
- **Rely only on CSS `prefers-color-scheme` media queries**: helps static CSS, but does not update JavaScript consumers, command state, editor integrations, or the localStorage startup mirror consistently.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Startup still avoids a light flash when the stored preference is `system` and the OS is dark.
|
||||
- Secondary windows that mount the shared theme hook receive the same resolved appearance and update on OS changes.
|
||||
- Code that reads `document.documentElement.dataset.theme` must treat it as a resolved `light` or `dark` value, not as the stored user preference.
|
||||
- Future theme variants should preserve this split between selected preference and resolved app theme rather than widening `data-theme` to non-renderable preference values.
|
||||
@@ -66,11 +66,11 @@ proposed → active → superseded
|
||||
| [0008](0008-underscore-system-properties.md) | Underscore convention for system properties | active |
|
||||
| [0009](0009-keyword-only-search.md) | Keyword-only search (remove semantic indexing) | active |
|
||||
| [0010](0010-dynamic-wikilink-relationship-detection.md) | Dynamic wikilink relationship detection | active |
|
||||
| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | active |
|
||||
| [0011](0011-mcp-server-for-ai-integration.md) | MCP server for AI tool integration | superseded → [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) |
|
||||
| [0012](0012-claude-cli-for-ai-agent.md) | Claude CLI subprocess for AI agent | active |
|
||||
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | active |
|
||||
| [0013](0013-remove-theming-system.md) | Remove vault-based theming system | superseded -> [0081](0081-internal-light-dark-theme-runtime.md) |
|
||||
| [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active |
|
||||
| [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | active |
|
||||
| [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | superseded → [0102](0102-low-end-safe-autosave-idle-window.md) |
|
||||
| [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active |
|
||||
| [0017](canary-release-channel-and-local-feature-flags.md) | Canary release channel and feature flags | superseded → [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) |
|
||||
| [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | superseded → [0064](0064-ratcheted-codescene-thresholds.md) |
|
||||
@@ -89,7 +89,7 @@ proposed → active → superseded
|
||||
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
|
||||
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
|
||||
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
|
||||
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
|
||||
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | superseded → [0085](0085-non-git-vault-support.md) |
|
||||
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
|
||||
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
|
||||
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
|
||||
@@ -126,6 +126,43 @@ proposed → active → superseded
|
||||
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
|
||||
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |
|
||||
| [0070](0070-starter-vaults-local-first-with-explicit-remote-connection.md) | Starter vaults are local-first with explicit remote connection | active |
|
||||
| [0071](0071-external-vault-refresh-and-clean-tab-reopen.md) | External vault updates reload derived state and reopen the clean active note | active |
|
||||
| [0071](0071-external-vault-refresh-and-clean-tab-reopen.md) | External vault updates reload derived state and reopen the clean active note | superseded → [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) |
|
||||
| [0072](0072-confirmed-vault-paths-gate-startup-state.md) | Confirmed vault paths gate startup state | active |
|
||||
| [0073](0073-persistent-linkify-protocol-registry-across-editor-remounts.md) | Persistent linkify protocol registry across editor remounts | active |
|
||||
| [0074](0074-explicit-external-ai-tool-setup-and-least-privilege-desktop-scope.md) | Explicit external AI tool setup and least-privilege desktop scope | active |
|
||||
| [0075](0075-crash-safe-note-rename-transactions.md) | Crash-safe note rename transactions | active |
|
||||
| [0076](0076-note-retargeting-separates-type-and-folder-moves.md) | Note retargeting separates type changes from folder moves | active |
|
||||
| [0077](0077-concurrent-safe-vault-cache-replacement.md) | Concurrent-safe vault cache replacement | active |
|
||||
| [0078](0078-scoped-unsigned-fallback-for-app-managed-git-commits.md) | Scoped unsigned fallback for app-managed git commits | active |
|
||||
| [0079](0079-linux-window-chrome-and-menu-reuse.md) | Linux window chrome and menu reuse | active |
|
||||
| [0080](0080-cross-platform-desktop-release-artifacts-and-portable-vault-names.md) | Cross-platform desktop release artifacts and portable vault names | superseded → [0083](0083-dual-architecture-macos-release-artifacts.md) |
|
||||
| [0081](0081-internal-light-dark-theme-runtime.md) | Internal light and dark theme runtime | active |
|
||||
| [0082](0082-markdown-durable-math-notes.md) | Markdown-durable math in notes | active |
|
||||
| [0083](0083-dual-architecture-macos-release-artifacts.md) | Dual-architecture macOS release artifacts | active |
|
||||
| [0085](0085-non-git-vault-support.md) | Non-git vaults open with explicit later Git initialization | active |
|
||||
| [0088](0088-markdown-durable-mermaid-diagrams.md) | Markdown-durable Mermaid diagrams in notes | active |
|
||||
| [0089](0089-active-vault-filesystem-watcher.md) | Active vault filesystem watcher | active |
|
||||
| [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active |
|
||||
| [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active |
|
||||
| [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | superseded -> [0103](0103-adapter-specific-ai-permission-semantics.md) |
|
||||
| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active |
|
||||
| [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active |
|
||||
| [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active |
|
||||
| [0096](0096-root-created-type-documents.md) | Root-created type documents | active |
|
||||
| [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active |
|
||||
| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | superseded → [0110](0110-in-app-media-and-pdf-file-previews.md) |
|
||||
| [0099](0099-cumulative-vault-asset-scope.md) | Cumulative vault asset scope for previews | active |
|
||||
| [0100](0100-synthetic-vault-root-folder-row.md) | Synthetic vault-root row in folder navigation | active |
|
||||
| [0101](0101-categorical-product-analytics-events.md) | Categorical product analytics events | active |
|
||||
| [0102](0102-low-end-safe-autosave-idle-window.md) | Low-end-safe autosave idle window | active |
|
||||
| [0103](0103-adapter-specific-ai-permission-semantics.md) | Adapter-specific AI permission semantics | active |
|
||||
| [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active |
|
||||
| [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active |
|
||||
| [0106](0106-shared-app-command-manifest.md) | Shared app command manifest | active |
|
||||
| [0107](0107-markdown-durable-tldraw-whiteboards.md) | Markdown-durable tldraw whiteboards in notes | active |
|
||||
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |
|
||||
| [0108](0108-sanitized-rendered-markup-and-safe-regex.md) | Sanitized rendered markup and safe user regex | active |
|
||||
| [0109](0109-debounced-worker-derived-editor-indexes.md) | Debounced worker-derived editor indexes | active |
|
||||
| [0110](0110-in-app-media-and-pdf-file-previews.md) | In-app media and PDF previews for binary vault files | active |
|
||||
| [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active |
|
||||
| [0112](0112-system-theme-mode.md) | System theme mode | active |
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs'
|
||||
import {
|
||||
createFixtureVaultCopy,
|
||||
openFixtureVault,
|
||||
removeFixtureVaultCopy,
|
||||
} from '../tests/helpers/fixtureVault'
|
||||
|
||||
// Minimal valid PNG: 1x1 red pixel
|
||||
const TEST_PNG_BASE64 =
|
||||
@@ -11,16 +16,28 @@ function createTestPng(filepath: string) {
|
||||
fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64'))
|
||||
}
|
||||
|
||||
test('image upload via file picker displays image with data URL', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(1000)
|
||||
let tempVaultDir: string
|
||||
|
||||
// Open a note
|
||||
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
async function openImageTestNote(page: Page) {
|
||||
await page.locator('[data-testid="note-list-container"]').getByText('Alpha Project', { exact: true }).click()
|
||||
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 10000 })
|
||||
return editor
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVault(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('image upload via file picker displays image with data URL', async ({ page }) => {
|
||||
const editor = await openImageTestNote(page)
|
||||
await editor.click()
|
||||
await page.waitForTimeout(200)
|
||||
|
||||
@@ -63,16 +80,37 @@ test('image upload via file picker displays image with data URL', async ({ page
|
||||
if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath)
|
||||
})
|
||||
|
||||
test('image paste into editor inserts image block', async ({ page }) => {
|
||||
const editor = await openImageTestNote(page)
|
||||
await editor.click()
|
||||
|
||||
await page.evaluate((base64) => {
|
||||
const editorElement = document.querySelector('.bn-editor')
|
||||
if (!editorElement) throw new Error('Editor not found')
|
||||
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
|
||||
const file = new File([bytes], 'pasted-image.png', { type: 'image/png' })
|
||||
const clipboardData = new DataTransfer()
|
||||
clipboardData.items.add(file)
|
||||
editorElement.dispatchEvent(new ClipboardEvent('paste', {
|
||||
clipboardData,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, TEST_PNG_BASE64)
|
||||
|
||||
const images = page.locator('.bn-editor img')
|
||||
await expect(images.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const src = await images.first().getAttribute('src')
|
||||
expect(src).toMatch(/^data:/)
|
||||
})
|
||||
|
||||
test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Click first note
|
||||
await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const editor = page.locator('.bn-editor')
|
||||
await expect(editor).toBeVisible({ timeout: 10000 })
|
||||
const editor = await openImageTestNote(page)
|
||||
|
||||
// Capture console errors
|
||||
const errors: string[] = []
|
||||
|
||||
101
index.html
101
index.html
@@ -7,17 +7,104 @@
|
||||
<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>
|
||||
// Apply saved theme before React mounts to prevent flash
|
||||
var t = localStorage.getItem('tolaria-theme');
|
||||
if (t === null) {
|
||||
t = localStorage.getItem('laputa-theme');
|
||||
if (t !== null) localStorage.setItem('tolaria-theme', t);
|
||||
}
|
||||
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
(function () {
|
||||
var key = 'tolaria-theme';
|
||||
var legacyKey = 'laputa-theme';
|
||||
var systemDarkQuery = '(prefers-color-scheme: dark)';
|
||||
function normalizeTheme(value) {
|
||||
return value === 'dark' || value === 'light' || value === 'system' ? value : null;
|
||||
}
|
||||
function prefersDarkTheme() {
|
||||
try {
|
||||
return typeof window.matchMedia === 'function' && window.matchMedia(systemDarkQuery).matches;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function resolveTheme(value) {
|
||||
var mode = normalizeTheme(value) || 'light';
|
||||
return mode === 'system' ? (prefersDarkTheme() ? 'dark' : 'light') : mode;
|
||||
}
|
||||
function applyTheme(value) {
|
||||
var mode = resolveTheme(value);
|
||||
document.documentElement.setAttribute('data-theme', mode);
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
}
|
||||
try {
|
||||
var mode = normalizeTheme(localStorage.getItem(key));
|
||||
if (mode === null) {
|
||||
mode = normalizeTheme(localStorage.getItem(legacyKey));
|
||||
if (mode !== null) localStorage.setItem(key, mode);
|
||||
}
|
||||
applyTheme(mode);
|
||||
} catch (err) {
|
||||
applyTheme('light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var readyEventName = 'tolaria:frontend-ready';
|
||||
var reloadAttemptKey = 'tolaria:startup-reload-attempted';
|
||||
var startupTimeoutMs = 10000;
|
||||
var isTauri = '__TAURI__' in window || '__TAURI_INTERNALS__' in window;
|
||||
|
||||
if (!isTauri) return;
|
||||
|
||||
function hasReloadAttempted() {
|
||||
try {
|
||||
return sessionStorage.getItem(reloadAttemptKey) === '1';
|
||||
} catch (err) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function markReloadAttempted() {
|
||||
try {
|
||||
sessionStorage.setItem(reloadAttemptKey, '1');
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearReloadAttempt() {
|
||||
try {
|
||||
sessionStorage.removeItem(reloadAttemptKey);
|
||||
} catch (err) {
|
||||
// Storage can be unavailable in hardened WebView/privacy modes.
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(readyEventName, clearReloadAttempt, { once: true });
|
||||
window.setTimeout(function () {
|
||||
if (window.__tolariaFrontendReady === true) return;
|
||||
if (hasReloadAttempted()) return;
|
||||
if (!markReloadAttempted()) return;
|
||||
|
||||
window.location.reload();
|
||||
}, startupTimeoutMs);
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
|
||||
612
lara.lock
Normal file
612
lara.lock
Normal file
@@ -0,0 +1,612 @@
|
||||
version: 1.1.0
|
||||
files:
|
||||
e6ab9bf36c30b9d72bc97897e0d89a16:
|
||||
command.noMatches: a2a97d96e196148e04c51c47d985e327
|
||||
command.palettePlaceholder: 6d6b61b4c08ed382ad7ba406a9ba395d
|
||||
command.footerNavigate: e3808db59d29bcfc91532d6539c4f9ae
|
||||
command.footerSelect: 6d73339906ed42e20f6be91bf7f5f4a9
|
||||
command.footerClose: dcf77460e548fe215ef28583f092a496
|
||||
command.footerSend: 6b19fc9694fd8b7b3f1aafaca83db864
|
||||
command.aiMode: 7197e3c5f9afd24022377176611f35cb
|
||||
command.openSettings: 638c05f4f159a403e04aebe94b46a2a8
|
||||
command.openSettings.keywords: ab6b5e03395f4db955ed9cbfd4de33b7
|
||||
command.openLanguageSettings: e5a425aa6222fab3df66c0e1e0ca4183
|
||||
command.openLanguageSettings.keywords: 6a910000cbf23eac3c7078e518cae66a
|
||||
command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca
|
||||
command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf
|
||||
command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
|
||||
command.contribute: 9887a4451812854f0f1b6f669a874307
|
||||
command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f
|
||||
command.group.navigation: 846495f9ceed11accf8879f555936a7d
|
||||
command.group.note: 3b0649c72650c313a357338dcdfb64ec
|
||||
command.group.git: 0bcc70105ad279503e31fe7b3f47b665
|
||||
command.group.view: 4351cfebe4b61d8aa5efa1d020710005
|
||||
command.group.settings: f4f70727dc34561dfde1a3c529b6205c
|
||||
command.navigation.searchNotes: d8002e888293774162e43b263658cbb5
|
||||
command.navigation.goAllNotes: 7030dcbbb85b6f01d3c8b2fdcb6a5cff
|
||||
command.navigation.goArchived: b620b8cb8fef8a287b986ff83a992a2a
|
||||
command.navigation.goChanges: c6dd094ce55f8a765b68cef40a1c6bd9
|
||||
command.navigation.goHistory: 39b86b661267053a240ffe89b9eca847
|
||||
command.navigation.goBack: 4f2f5e1d6e2a13469ad431474a68ab82
|
||||
command.navigation.goForward: d13c318438f67d749cfc0904e3fafcda
|
||||
command.navigation.goInbox: 522af71c26bc7e3ce1dae6204c2fbe9f
|
||||
command.navigation.renameFolder: a3d3b0f787c52a984af61085c577bff2
|
||||
command.navigation.deleteFolder: 991e322cd8225c369a963bb60b0a9688
|
||||
command.navigation.showOpenNotes: bd1905c340d0518187865f4a6694e14d
|
||||
command.navigation.showArchivedNotes: 241399b908884adfe8608a3ab827ca74
|
||||
command.navigation.listType: 4f70c27c6826a37543c8ada561397c22
|
||||
command.note.newNote: 23c4edda8b815f750782f01870d27271
|
||||
command.note.newType: adce5108f18945cc502a06c02445e32d
|
||||
command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2
|
||||
command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047
|
||||
command.note.pastePlainText: 09e8075dbd91e11878f8f4a138df760c
|
||||
command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc
|
||||
command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1
|
||||
command.note.deleteNote: 56f727a2ee11d15159f1aa6373314cb6
|
||||
command.note.archiveNote: 6043509fda6dd7f6f167e4108033f8e8
|
||||
command.note.unarchiveNote: 6937fb694d00b11d579c21cd4bf103a2
|
||||
command.note.addFavorite: 782d6b28dce4e5d0b2ac92142c6624ba
|
||||
command.note.removeFavorite: 4f9b5fa08f2ef86fdc5d0ceefc271981
|
||||
command.note.markOrganized: 505ad63357f533fb39e4276a2bfb9496
|
||||
command.note.markUnorganized: 1c62f1bffcb793c63d729096d2794ced
|
||||
command.note.restoreDeleted: d704f8283ca322a9b3713d2ccf9a6cd4
|
||||
command.note.setIcon: 99b81d2663266c53e45e4d9c62c87a29
|
||||
command.note.removeIcon: 4bccbad66025e506b37b4218498b299c
|
||||
command.note.changeType: 4a54ffd47ed1f65ee97f34bfe8c3de14
|
||||
command.note.moveToFolder: 3a0f26c42b9168ad839960d134065905
|
||||
command.note.openNewWindow: 6f67b2857093fa0fd45b1122dff4865d
|
||||
command.git.initialize: 54a57cdc7105db6fb22dca661ce01ad6
|
||||
command.git.commitPush: 8cb5faa4019716f43dd69c41496b1d46
|
||||
command.git.addRemote: 7eef951b3f909340a68dc9811be83e9e
|
||||
command.git.pull: 3988d61f4a4546381f61a4eaf61315b4
|
||||
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
|
||||
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
|
||||
command.view.editorOnly: 8355e056b32086b9190d639be290dda8
|
||||
command.view.editorNoteList: b9604b1609eb6f581cd9570dca72d3f0
|
||||
command.view.fullLayout: 1cebdf839eee2b1713828731aaa3b507
|
||||
command.view.toggleProperties: 54f12756a363401243d82cbd0466117e
|
||||
command.view.toggleDiff: 148a10ef6f04f897e73289f529ecae86
|
||||
command.view.toggleRaw: 9b622257cd6345ceaf58e42b1410899b
|
||||
command.view.noteWidthNormal: d29ab88a55ae178bf4b5257201e51801
|
||||
command.view.noteWidthWide: 403f3913ad71451a90787c235f1684de
|
||||
command.view.defaultNoteWidthNormal: b045349e37329df23317db98cccd3263
|
||||
command.view.defaultNoteWidthWide: 41f326763e3e3954912dfd45d04bd87c
|
||||
command.view.leftLayout: 5046a7166675e2d0175b9da3befdcaca
|
||||
command.view.centerLayout: 3bc6759c005178aae519aa3dd227cb74
|
||||
command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e
|
||||
command.view.newAiChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
|
||||
command.view.toggleBacklinks: b3c4be2d2c07bb8df181355a2c3658a7
|
||||
command.view.moveViewUp: 76f92d3250e028609349d825c672f13e
|
||||
command.view.moveViewDown: 9deea30162b8d3234f8d52795ad7393f
|
||||
command.view.moveNamedViewUp: b4ac634d13a09eacb680ef12c69dd1f7
|
||||
command.view.moveNamedViewDown: 15302c02df26fe52def77636e9141711
|
||||
command.view.zoomIn: ae4a8e406b707636392b6673fca0e6a6
|
||||
command.view.zoomOut: 97326f8cd9df886a6b19af180fb7dcc9
|
||||
command.view.resetZoom: 193ee8c32391fc5cc303a51617cfd046
|
||||
command.settings.createEmptyVault: d7c9673bb6c62a2b8aef009546ce8b23
|
||||
command.settings.openVault: c40e74822da9417fdf52eb4a37bb23f2
|
||||
command.settings.removeVault: 390e2b2031cde0dd18381efee0923bea
|
||||
command.settings.restoreGettingStarted: b3358dc1c7b873b2a02ac6f05473fe4d
|
||||
command.settings.manageExternalAi: c36b9bd171f11a18a1e624365d2b57ac
|
||||
command.settings.setupExternalAi: 24eea679eb699763daa2e3f2a67fcf9a
|
||||
command.settings.reloadVault: f6e506dad6b6cf2be24d9438d458ae54
|
||||
command.settings.repairVault: cee560b1fd5ad9d1ff1c19a0a2afe77a
|
||||
command.settings.useLightMode: a76d5b1c076983bc113d247f0520c166
|
||||
command.settings.useDarkMode: ba7873c42ae00542ba71db9be3b6761f
|
||||
command.settings.useSystemTheme: 6d8c69d916d3df8ceed8dfc154196d07
|
||||
command.settings.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03
|
||||
command.ai.openAgents: 612abe804edd0f5ae228a534f77f3d23
|
||||
command.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
|
||||
command.ai.switchToAgent: 5bb02187e653b360ab7ed661403271f2
|
||||
command.ai.switchDefault: 42724de254240d598fbcc40fdb5b18a3
|
||||
command.ai.switchDefaultWithAgent: d405f7914bb1cd86172f108f3c33d953
|
||||
settings.title: f4f70727dc34561dfde1a3c529b6205c
|
||||
settings.close: 7812b3644f897c5d3efb0dd63440e751
|
||||
settings.sync.title: 36f62a237420dcdc7096ad3a9018c4d9
|
||||
settings.sync.description: 06a6957c40867be2c28783183dc1bf5f
|
||||
settings.pullInterval: 4f95ca677398604bca241427261ed07b
|
||||
settings.pullIntervalDescription: 4a75c15f44d59dc8cb100ec2d0693048
|
||||
settings.releaseChannel: 2da37055e15a217f18bd403922085c3f
|
||||
settings.releaseChannelDescription: c5ae9b7f25376df383d36603366ac1fa
|
||||
settings.releaseStable: fa3aff3c185c6dc7754235f397c2099a
|
||||
settings.releaseAlpha: 6132295fcf5570fb8b0a944ef322a598
|
||||
settings.appearance.title: a1c58e94227389415de133efdf78ea6e
|
||||
settings.appearance.description: ca7b75a4c10ab8a540707e3a96cd3592
|
||||
settings.theme.label: d721757161f7f70c5b0949fdb6ec2c30
|
||||
settings.theme.light: 9914a0ce04a7b7b6a8e39bec55064b82
|
||||
settings.theme.dark: a18366b217ebf811ad1886e4f4f865b2
|
||||
settings.theme.system: a45da96d0bf6575970f2d27af22be28a
|
||||
settings.language.title: 4994a8ffeba4ac3140beb89e8d41f174
|
||||
settings.language.description: e2bb9b523067fdc32a494b1d6fd97906
|
||||
settings.language.label: 244c7b77926f077b863c33ff1244e1ec
|
||||
settings.language.system: 41e2252344aa441e925589ddcb762e6d
|
||||
settings.language.summary: edc83f1f48ce29bc80bf2f2f90e24997
|
||||
settings.autogit.title: e59f720605ec1cfa42b3b97d89d7e883
|
||||
settings.autogit.description.enabled: e65bec70ca4d7921be56f84d10836018
|
||||
settings.autogit.description.disabled: 1143ef6ee00614816785e3c6fc299d51
|
||||
settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75
|
||||
settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554
|
||||
settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6
|
||||
settings.autogit.idleThresholdDescription: 521140491e8c9d5a1d7b7739bc333358
|
||||
settings.autogit.inactiveThreshold: d28cb60d1e70e020ae8d1294e32dd474
|
||||
settings.autogit.inactiveThresholdDescription: f9e43c90bd10bae937c2cfa5c2767f1c
|
||||
settings.titles.title: 761443f70a5067ecf015c6fb3fae9cef
|
||||
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
|
||||
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
|
||||
settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e
|
||||
settings.vaultContent.title: 78b883ce9acb9c25e611158a518d9185
|
||||
settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d
|
||||
settings.noteWidth.default: 66be0f882801fd19468181f31f58dc20
|
||||
settings.noteWidth.defaultDescription: 2b5fb6d7dfa2b1da1e47bcac30f58e8d
|
||||
settings.noteWidth.normal: 960b44c579bc2f6818d2daaf9e4c16f0
|
||||
settings.noteWidth.wide: e7c770a61dbdf81ca922ae0260e327c1
|
||||
settings.sidebarTypePluralization.label: 1e5bd615b1abbf39129ef241f5cf2249
|
||||
settings.sidebarTypePluralization.description: 8d7bb0649fd7e637552429ea91298de8
|
||||
settings.vaultContent.hideGitignored: 2c07ee6c8bfe746d356f44275d42f90e
|
||||
settings.vaultContent.hideGitignoredDescription: e8b1ddfa416610f9d6eb299fa123a1d6
|
||||
settings.allNotesVisibility.title: 0e07c3d48b91e1a4c42c1ff3e5751ec3
|
||||
settings.allNotesVisibility.description: bc9caa48296281401aad694fed65a2d9
|
||||
settings.allNotesVisibility.pdfs: 27ce5e1e1761ff362dbf75c76c6b4941
|
||||
settings.allNotesVisibility.pdfsDescription: 8191ed4a095cdd60ef856ce0ff213a48
|
||||
settings.allNotesVisibility.images: ea15460e63b6e8e92f8c03a79eeb6e4e
|
||||
settings.allNotesVisibility.imagesDescription: 1704e10d5e5cdc17d2d2a5fb7b8969bf
|
||||
settings.allNotesVisibility.unsupported: 257fe04419c9f6543a43e58b1edf9eb5
|
||||
settings.allNotesVisibility.unsupportedDescription: a3b8aa66900c742f001cd4533b3df44a
|
||||
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
|
||||
settings.aiAgents.description: 042a8037f1a4f90c76ce31a49fdff59c
|
||||
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
|
||||
settings.aiAgents.defaultTarget: 5acfc74fd97a6dd2d67d15c66de5eed5
|
||||
settings.aiAgents.agentGroup: 965530cbbec45b1754ed3ece120399f7
|
||||
settings.aiAgents.localGroup: d34f4b997ce78da5aa57d657a5d6fcb5
|
||||
settings.aiAgents.apiGroup: 589a257cbd265eaa7de93de8b4084dff
|
||||
settings.aiAgents.installed: 73329564760013a7824ff9d5d1af91ff
|
||||
settings.aiAgents.missing: ea21841da70e6405af19fabc4ff8bdd9
|
||||
settings.aiAgents.ready: 909a648c713be25fcd050725d0a41e37
|
||||
settings.aiAgents.notInstalled: 0ac3f76ef78bfdbab6331731ee56ba22
|
||||
settings.aiAgents.apiReady: e5b06f6ef4ef5f4bc2848d46195e3f48
|
||||
settings.aiAgents.apiLocalKey: 119de2f31867b273a134f05bc56b9e57
|
||||
settings.aiAgents.apiEnv: e011db3dff54ed3deae37a9615e31ebf
|
||||
settings.aiAgents.apiNoKey: 5bbccbfcd09b28902bbf5319dd6e6053
|
||||
settings.aiAgents.installedTitle: 5cb9b5ae5ebc66652814dff4584705cc
|
||||
settings.aiAgents.installedDescription: fb9ba9667725fb40fa1e5d58a2c02692
|
||||
settings.aiAgents.noVersion: 89831adb7114946e916b9c31d57a1e33
|
||||
settings.aiProviders.title: 63eb84d8510f9f086d282ec7d47adaa1
|
||||
settings.aiProviders.description: 6c86c013eb6fb198b51e29ec47d66464
|
||||
settings.aiProviders.localTitle: c7c20443d8aaf04271df9d713f5c02ce
|
||||
settings.aiProviders.localDescription: 7abed40ef2e46741c64ff47e423cca39
|
||||
settings.aiProviders.apiTitle: 66537b9e5200ebbbfbd091144d8afbcc
|
||||
settings.aiProviders.apiDescription: 6ec773352e05a805af3abcebeff4e756
|
||||
settings.aiProviders.kind: 27703c8f150ac4bb0a3a83a7857353af
|
||||
settings.aiProviders.kind.ollama: c23e8db458397f37c8e8d98e94e55a18
|
||||
settings.aiProviders.kind.lmStudio: 7b9b319662715e90583e823a6c3cfdbe
|
||||
settings.aiProviders.kind.openAi: 0523b13262b12c215d8009938f5c14f1
|
||||
settings.aiProviders.kind.anthropic: f431db65cea024a5f19eab835afefee2
|
||||
settings.aiProviders.kind.gemini: 766cc4dd4d5005652e8514e3513683f8
|
||||
settings.aiProviders.kind.openRouter: a0607683815b6585fb80e7cac72ac59a
|
||||
settings.aiProviders.kind.compatible: 1a4dc90a183c26d9026c6ac7727aa4cd
|
||||
settings.aiProviders.name: 49ee3087348e8d44e1feda1917443987
|
||||
settings.aiProviders.baseUrl: ade86bc4899761ad46c52e381b6228bb
|
||||
settings.aiProviders.model: 6bd36c36869288188863bd53a9bc5ed1
|
||||
settings.aiProviders.key: 656a6828d7ef1bb791e42087c4b5ee6e
|
||||
settings.aiProviders.keyPlaceholder: 57dbf3720af3f156018bfff7f5a99b23
|
||||
settings.aiProviders.keyStorage: 3e5b8cfbc66d3a33dad3fda058bf5851
|
||||
settings.aiProviders.keyStorage.local: 7c6189f734f3c66d162a3f06529af1d9
|
||||
settings.aiProviders.keyStorage.env: a71b071971d7c54e9af381c9bea093d9
|
||||
settings.aiProviders.keyStorage.none: 94c841331ccd5179a283487f96285c90
|
||||
settings.aiProviders.keyEnv: cb72671bd5cf65512d4c94e8ad6182ef
|
||||
settings.aiProviders.keySafety: 0d59d7e4def35e51c4bb785d74180afc
|
||||
settings.aiProviders.keySafetyLocal: 8ef2ba0192ac725fa1a3e737e852387c
|
||||
settings.aiProviders.localSafety: 60fb65e4e0d35cec3135584d8219e8fd
|
||||
settings.aiProviders.add: a9f8e8b4ca3cbc4fc3bff5d59d6a5419
|
||||
settings.aiProviders.addLocal: 681985f2e412f22fcd55a0553f6e4bee
|
||||
settings.aiProviders.addApi: 767b46f2a51aa9dd050cb226cd86e192
|
||||
settings.aiProviders.test: 602009d5aea1c1ef06fd777968f7e80e
|
||||
settings.aiProviders.testing: 9d770c909c2c69b09eae2372c4cf405d
|
||||
settings.aiProviders.testSuccess: 51677ed54231e41a4fe40850d90b64b2
|
||||
settings.aiProviders.empty: 9378027dec7f93fcc5011c5e528bdf82
|
||||
settings.aiProviders.defaultEndpoint: 666b355c305dd4c7b4649d3d7b4c5d11
|
||||
settings.aiProviders.keyLocalSaved: f4a102d6a67212e21f0f31f0758aea81
|
||||
settings.aiProviders.keyEnvSaved: bfed2d745dd76dbea9341d27e06c39f2
|
||||
settings.aiProviders.noKey: 1ab089f80ef4dc6c1ce3e173f5b405a0
|
||||
settings.workflow.title: 24f47cdbe9ddba774a7cc53e51d9032e
|
||||
settings.workflow.description: aa9a07539b87cf407c3edc8a22732d45
|
||||
settings.workflow.explicit: 54a5b5c27937d25271c3127d093e8361
|
||||
settings.workflow.explicitDescription: ce523427b5dd0f9895f63d1fbb90ad24
|
||||
settings.workflow.autoAdvance: 9ed337f5bc61603d19a1ef35f3a3a243
|
||||
settings.workflow.autoAdvanceDescription: ae58ad8abf03df7cbdae8c3a725258f7
|
||||
settings.privacy.title: aa96a21412def0d916f43b639424f8e4
|
||||
settings.privacy.description: 33d53059be620b58e38b8e5c5ab26b27
|
||||
settings.privacy.crashReporting: b4efc5b61526566d431e99242f5c21b4
|
||||
settings.privacy.crashReportingDescription: e9d7c7efce44adc08d0460be1aad5c42
|
||||
settings.privacy.analytics: 29d55bb222ea76e1a17396375dfab228
|
||||
settings.privacy.analyticsDescription: a0f8b61d555d6d3fd279de2f47aaca76
|
||||
settings.footerShortcut: 7dc840d6b0ef9f422a663ba41975871e
|
||||
settings.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
settings.save: c9cc8cce247e49bae79f15173ce97354
|
||||
common.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
common.create: 686e697538050e4664636337cc3b834f
|
||||
common.save: c9cc8cce247e49bae79f15173ce97354
|
||||
common.remove: 1063e38cb53d94d386f21227fcd84717
|
||||
customize.color: cb5feb1b7314637725a2e73bdc9f7295
|
||||
customize.icon: 817434295a673aed431435658b65d9a7
|
||||
customize.searchIcons: 0688a8098567785a05cb4cf937cbb880
|
||||
customize.noIconsFound: 89c25bea716a589b11599f2bbcf34054
|
||||
customize.template: 278c491bdd8a53618c149c4ac790da34
|
||||
customize.templatePlaceholder: 44482755791f96f37118f3a3bc66f668
|
||||
customize.done: f92965e2c8a7afb3c1b9a5c09a263636
|
||||
viewDialog.title.create: 01e53f288ebe0b7bdda19c8151aa7710
|
||||
viewDialog.title.edit: 6369f1c063cb2c22efa584a19e98d97f
|
||||
viewDialog.description.create: a9600940be61edfcad2b47fb72965406
|
||||
viewDialog.description.edit: d4e6333950d5031c5f4ae766a1f1d886
|
||||
viewDialog.nameLabel: 49ee3087348e8d44e1feda1917443987
|
||||
viewDialog.namePlaceholder: dbb077d36957a33c4147eb24919192e1
|
||||
viewDialog.filtersLabel: f3f43e30c8c7d78c6ac0173515e57a00
|
||||
viewDialog.saveError: 80fe81f3ca09ea03af03fdb9ae809eda
|
||||
viewDialog.selectedIcon: 5f98b507ccec0bbc51ca7e8a5b270250
|
||||
viewDialog.selectedColor: 192ec1a9973f328c3620cd3fa670c745
|
||||
ai.permission.safe.short: c6eea0560cd6f377e78dff2c85cc9122
|
||||
ai.permission.safe.control: 386cc66605d50e5bf1fb726f97e55b55
|
||||
ai.permission.safe.tooltip: 3bd635e2b2223da302994d77bfd7edfd
|
||||
ai.permission.powerUser.short: d4a47db271fc1ef3cd17f7396326b057
|
||||
ai.permission.powerUser.control: d4a47db271fc1ef3cd17f7396326b057
|
||||
ai.permission.powerUser.tooltip: 2585ecdb79ca02a3ee779bb61552de03
|
||||
ai.permission.modeAria: 3ff6346566a4c44fd5c7395907125d3e
|
||||
ai.permission.changed: b5d06ad47e6053fe5a73f4571a40e00c
|
||||
ai.panel.title: 4412225fe5b326643b8c2e1d9b7b10ae
|
||||
ai.panel.status.checking: 118740af1c4521b917e29791d661db82
|
||||
ai.panel.status.missing: fe07f8eac233c229d81cbe9aa25668a0
|
||||
ai.panel.status.ready: 296a0ac791eab2df130789f55fdc109b
|
||||
ai.panel.mode.chat: 55dcdf017b51fc96f7b5f9d63013b95d
|
||||
ai.panel.mode.chatDescription: d7744ae39e7920a8eac7b21b58fce348
|
||||
ai.panel.copyMcpConfig: 6a8c9fe401557c870e3a90bbfab01b25
|
||||
ai.panel.mcpConfig: 53bac93d0314941c8d2c1163026f3ad9
|
||||
ai.panel.newChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
|
||||
ai.panel.close: d00ba22b83762f5a6e66db0be9e916d7
|
||||
ai.panel.linkedCount: 5292fda189a357c9597b1cb0e1b0900b
|
||||
ai.panel.empty.checkingTitle: 0dde2076cb53fe497d05d231296d50bb
|
||||
ai.panel.empty.checkingDescription: 31e41510d851c366860377ba0c956f0b
|
||||
ai.panel.empty.missingTitle: a2b764da52cb43b191bb4ecfd8ea4d26
|
||||
ai.panel.empty.missingDescription: 91825c7d86b1d588d44a33e1d94af950
|
||||
ai.panel.empty.withContextTitle: 66f02985937083e4ef62b98cc9f25aee
|
||||
ai.panel.empty.noContextTitle: e919aeb0a49b58e494e43e5f6f6597c9
|
||||
ai.panel.empty.withContextDescription: dd8faefcc8160a2db800a41d6628facb
|
||||
ai.panel.empty.noContextDescription: 6a6e610835808016d1f2790c3f991a54
|
||||
ai.panel.placeholder.checking: 08fec77719c37d3d6279b2641b37b4d3
|
||||
ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9
|
||||
ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114
|
||||
ai.panel.send: 747c6a3564774149c989884e0c581d53
|
||||
mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01
|
||||
mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c
|
||||
mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa
|
||||
mcp.setup.setupDescription: 8bf199ffbcf303a99abac078c0c5e094
|
||||
mcp.setup.reconnect: 813ba447b5e64c17ff9b68c693358ca3
|
||||
mcp.setup.connect: fb95777dbc9f7b22014dc360a2957652
|
||||
mcp.setup.disconnect: 42ae25231906c83927831e0ef7c317ac
|
||||
mcp.setup.connecting: 182e7eda4e721ad00737460b88701dee
|
||||
mcp.setup.disconnecting: 11de134aefe51cb4a5c545b5a057a871
|
||||
mcp.setup.nodeRequirement: ac58a35cf21d2204fed548c227f77594
|
||||
mcp.setup.writeEntryDescription: 370aa4403d2ffc37f574503bc3cd00e6
|
||||
mcp.setup.clientPathsDescription: 94565852412051c799e78d2f4525ff53
|
||||
mcp.setup.geminiGuidanceDescription: 182cf5b84b2f56cb1cf41dcdb5e093d8
|
||||
mcp.setup.manual.title: 2188ca52346158cbd103ca7ce7c12dd9
|
||||
mcp.setup.manual.copy: 6a8c9fe401557c870e3a90bbfab01b25
|
||||
mcp.setup.manual.loading: 11ee201c121c8e2015c065952474a3ea
|
||||
mcp.setup.manual.unavailable: 8b4330b839c41bad378c86c91ee0b5cd
|
||||
mcp.toast.connected: 7716c0f9dad41a7fdbef592074459239
|
||||
mcp.toast.refreshed: 227d8721ebec3077792b1223c1f30e54
|
||||
mcp.toast.disconnected: 9b5254079561087d679e35aa10c80638
|
||||
mcp.toast.alreadyDisconnected: 645bec2e33bd9de2b334b704d9fa84cf
|
||||
mcp.toast.configCopied: 458b50444b9a1cb069b00475c0951b8a
|
||||
mcp.toast.configCopyFailed: b451cfb95f87458d142d026a8aca74cf
|
||||
mcp.toast.setupFailed: 51f290962c08d8ba76bfc6c19552eaed
|
||||
mcp.toast.disconnectFailed: ddc3387ab6e996ce15bc4950a3548e1e
|
||||
mcp.error.clipboardUnavailable: 3b9ed3fe7dba627edb74e1cdaa02b2ee
|
||||
save.toast.saved: 248336101b461380a4b2391a7625493d
|
||||
save.toast.nothingToSave: 86ae8cef7371e639f3c007449a2f7d1f
|
||||
save.toast.missingActiveVault: 49cf5064a7dce757f895950d8beeed68
|
||||
save.error.failed: b55b9fefe93fabae3c277e4e7956a534
|
||||
save.error.autoFailed: 09b2b131f07baddb5ba8f5a3ee6234c5
|
||||
save.error.invalidPath: c96fb669715e86508f4c5e73da9f6995
|
||||
savedViews.reordered: 531081a566b826eed2e714ce70a0de08
|
||||
locale.en: 78463a384a5aa4fad5fa73e2f506ecfc
|
||||
sidebar.nav.inbox: 3882d32c66e7e768145ecd8f104b0c08
|
||||
sidebar.nav.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5
|
||||
sidebar.nav.archive: e727b00944f81e1d0a95c12886ac4641
|
||||
sidebar.group.favorites: 953bf4d5e8a1807c15dec4e255384b1d
|
||||
sidebar.group.views: 8f56a72923a6ca8ff53462533d89e567
|
||||
sidebar.group.types: 6b20155008db90cf7589c07baa7334e9
|
||||
sidebar.group.folders: d86e2756f698bea5aac3e2276639f042
|
||||
sidebar.action.createView: ba019414ea8c7fcdb4a83a70ec1bb639
|
||||
sidebar.action.editView: acdf34bd08b0692b906d446e590b1eb9
|
||||
sidebar.action.renameView: 429b903fddf39bb21887282d188c4b62
|
||||
sidebar.action.deleteView: c8f4f9cd8ff820f955474e5c9f70ff39
|
||||
sidebar.action.reorderView: ca2f42548fd57160d22fd0809bf8ed6e
|
||||
sidebar.action.moveViewUp: e601b7f0901f12efb71f7821c0fdb79e
|
||||
sidebar.action.moveViewDown: 6f86951085367864e3e4eb13f5c8688e
|
||||
sidebar.action.customizeSections: 050a134cad1e9c6f04abe5d635592703
|
||||
sidebar.action.renameSection: 8936914051df04e7550d0eeb4a31e0e0
|
||||
sidebar.action.renameType: 327d51d97d4b0e3ddcb7afa39c4bf5fa
|
||||
sidebar.action.customizeIconColor: 9a2f685c74d261ab483ef00dee5314fe
|
||||
sidebar.action.deleteType: c52e15c44f6372c6ddb2758254a041ef
|
||||
sidebar.action.createType: 6cfb8b8aa3fdce38e675819691b48f5c
|
||||
sidebar.action.collapse: 00873bdddd457791609bff1243c40a6b
|
||||
sidebar.action.expand: 8f98e9696f6eed958573012f52710faa
|
||||
sidebar.action.createFolder: 5dc1e97c14fbe72d8b566ce29c39f010
|
||||
sidebar.action.renameFolder: db76034f8218cda07dadb746a5643019
|
||||
sidebar.action.deleteFolder: 2a2f15300f6f7c81d69860ac1796b517
|
||||
sidebar.action.revealFolderMenu: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
sidebar.action.copyFolderPathMenu: 1779ec6018a385912c1a5499a1bbf2b2
|
||||
sidebar.action.renameFolderMenu: deb1d8fa030292e7eda2c244b313aca2
|
||||
sidebar.action.deleteFolderMenu: c78c889ac7396dc148fc859c9221e545
|
||||
sidebar.view.name: 67b904f75d1872abba088a3442caff40
|
||||
sidebar.folder.name: 710417c4e57a6a01500fe4c0c448ce3d
|
||||
sidebar.folder.newName: e0ad5b2db20359a85de80c1231323bea
|
||||
sidebar.folder.expand: b7545cb143816942ff096d890090fb6f
|
||||
sidebar.folder.collapse: e9f8c6da708219313d72d99cc3998388
|
||||
sidebar.section.name: c3642037e309db278bbbfe0590114c02
|
||||
sidebar.section.showInSidebar: e38d073926d6fb3dfc4d4347708e3cf5
|
||||
sidebar.section.toggle: 2992ffbc76e607a2dc0e99781f101637
|
||||
sidebar.disabled.comingSoon: 81151a1669ebd695e837f304a0d8ec79
|
||||
noteList.title.archive: e727b00944f81e1d0a95c12886ac4641
|
||||
noteList.title.changes: c112bb3542e98308d12d5ecb10a67abc
|
||||
noteList.title.inbox: 3882d32c66e7e768145ecd8f104b0c08
|
||||
noteList.title.history: 16d2b386b2034b9488996466aaae0b57
|
||||
noteList.title.view: 4351cfebe4b61d8aa5efa1d020710005
|
||||
noteList.title.notes: f4c6f851b00d5518bf888815de279aba
|
||||
noteList.searchPlaceholder: 4857cd2689a0a40eb4a77cdc745c518e
|
||||
noteList.searchAction: 5012120fc480c67686dd22ee2f9493cd
|
||||
noteList.createNote: f1484bc40bd3fe3430c13fb89e2ce1af
|
||||
noteList.empty.changesError: 4fca500c4b70f61f7d9413c57c34bdc4
|
||||
noteList.empty.noChanges: ad82ac153532f5625760c29c176c5d5f
|
||||
noteList.empty.noArchived: 75fa9e2aaa902f65d433be856a2e8331
|
||||
noteList.empty.noMatching: 22838e3b8d3d174d33d7986ab6bdd693
|
||||
noteList.empty.allOrganized: ea8fbb54d21c03fae1469635e7043b9b
|
||||
noteList.empty.noNotes: 910bd677fdd52f3e6edb4abc1d03ade8
|
||||
noteList.empty.noMatchingItems: 25cd76c4bbd00b682a45687705b41a14
|
||||
noteList.empty.noRelatedItems: 67eecd103b56ef0e56fd892d379b5a5e
|
||||
noteList.sort.modified: 35e0c8c0b180c95d4e122e55ed62cc64
|
||||
noteList.sort.created: 0eceeb45861f9585dd7a97a3e36f85c6
|
||||
noteList.sort.title: b78a3223503896721cca1303f776159b
|
||||
noteList.sort.status: ec53a8c4f07baed5d8825072c89799be
|
||||
noteList.sort.by: 5cd12b67b005056a94f06368a7645b8e
|
||||
noteList.sort.menu: 62bfadcf958eb6ad238e6c71e312a0ce
|
||||
noteList.sort.ascending: cf3fb1ff52ea1eed3347ac5401ee7f0c
|
||||
noteList.sort.descending: e3cf5ac19407b1a62c6fccaff675a53b
|
||||
noteList.filter.open: c3bf447eabe632720a3aa1a7ce401274
|
||||
noteList.filter.archived: 7d69b3cb4cada18ae61811304f8fbcb5
|
||||
noteList.filter.week: d2ce009594dcc60befa6a4e6cbeb71fc
|
||||
noteList.filter.month: 7cbb885aa1164b390a0bc050a64e1812
|
||||
noteList.filter.all: b1c94ca2fbc3e78fc30069c8d0f01680
|
||||
noteList.properties.customizeColumns: be39bb193d872e11bb21388add6eca23
|
||||
noteList.properties.customizeAllColumns: 0d382ece9a9d08bb9ddf10ed2c88d726
|
||||
noteList.properties.customizeInboxColumns: e7cf53b914ce1079801eeee603875660
|
||||
noteList.properties.customizeViewColumns: a5612e5d2aa2e86941d536a85b27c8b4
|
||||
noteList.properties.showInNoteList: f22a57c5fc14ff477000cc66d837c344
|
||||
noteList.properties.searchPlaceholder: 9c7569eb531ff041b2b7cf4de1e5a619
|
||||
noteList.properties.searchLabel: de1bc9695d5e79a75296abf74dc25056
|
||||
noteList.properties.noMatches: 1627ecc53b9e97c94b2b6b1b93a58721
|
||||
noteList.properties.reorder: c0955c7efaa1ce90d449a23cbc52b553
|
||||
noteList.changes.restoreNote: d6e99303c0e0b7b2abce06fe9214788b
|
||||
noteList.changes.discardChanges: 98313f623bb6f464b9a154eca0b99bf3
|
||||
noteList.changes.restoreDescription: 45e13380b0538e6cbff330f99c260c0a
|
||||
noteList.changes.discardDescription: cd953f51a81e10f8c90135e0106a45d6
|
||||
noteList.changes.thisFile: 976b976e66879a470635bf0f660e81fc
|
||||
noteList.changes.restore: 2bd339d85ee3b33e513359ce781b60cc
|
||||
noteList.changes.discard: d94b42030b9785fd754d5c1754961269
|
||||
noteList.changes.cancel: ea4788705e6873b424c65e91c2846b19
|
||||
editor.empty.selectNote: a71ec7c80aefe49c59f6aba033bf453e
|
||||
editor.empty.shortcuts: 1133fdf3ecef6a09dd9e2a185e1bd4ec
|
||||
editor.raw.label: 7a9754c15264b0604aa31dfea32321ea
|
||||
editor.find.findLabel: 4cfa6c981549e990fe2344e4c805405e
|
||||
editor.find.findPlaceholder: 4cfa6c981549e990fe2344e4c805405e
|
||||
editor.find.replaceLabel: 0ebe6df8a3ac338e0512acc741823fdb
|
||||
editor.find.replacePlaceholder: 0ebe6df8a3ac338e0512acc741823fdb
|
||||
editor.find.matchCount: 29d3cd243c6850f5fbac283e79d6d6a7
|
||||
editor.find.noMatches: 3b470c1f6a10f58c8a8cb6b904577786
|
||||
editor.find.invalidRegex: 9704b2ccc6158864810a313702019d68
|
||||
editor.find.regexMustMatchText: 267728c2e7f2889b39847ee9b9b38891
|
||||
editor.find.previousMatch: afb1b7e6bbdfaa97114e1b519a817f13
|
||||
editor.find.nextMatch: 7f4d218b4f566ea7ff69ab8d912ba7b9
|
||||
editor.find.showReplace: 82c8fdb73dfe8baa1759a36c6c0fb282
|
||||
editor.find.hideReplace: a85baf9735f9acf26aa15b70f2ee55fb
|
||||
editor.find.regex: 7486f19f279f95357c84706dd09ce9da
|
||||
editor.find.matchCase: ba945280b8802ee1a7a50140e2fe94e2
|
||||
editor.find.close: b040fbda901d2135cad53e54e0127071
|
||||
editor.find.replace: 0ebe6df8a3ac338e0512acc741823fdb
|
||||
editor.find.replaceAll: b1c94ca2fbc3e78fc30069c8d0f01680
|
||||
editor.toolbar.rawReturn: 5ddcf5e0dc8b933b344bb6ca3d8e131d
|
||||
editor.toolbar.rawOpen: 89ad60735a649b60dacd2301cda0c4ec
|
||||
editor.toolbar.noteWidthNormal: 9bb55413ec536c4a8fa41c4c4464ab44
|
||||
editor.toolbar.noteWidthWide: 1a744f406c9150f76739bd167c3e47ec
|
||||
editor.toolbar.centerLayout: 3fabf7e9b285eeec90704d271f1049b7
|
||||
editor.toolbar.leftLayout: acab6c8612816e012ff9f4220a42e485
|
||||
editor.toolbar.removeFavorite: 7188286e36fc6c1892dd53aaa54237fb
|
||||
editor.toolbar.addFavorite: 910885435dbc44a22b68cb45c79e4a7e
|
||||
editor.toolbar.markUnorganized: 83fb1b23a3609fab493737fe7af0a198
|
||||
editor.toolbar.markOrganized: 9d170c916afae3d7a82456838728ffa5
|
||||
editor.toolbar.openNeighborhood: 80f659c99954ab7681266b5a1e5da6df
|
||||
editor.toolbar.noDiff: b2c4189f90648aaeb5cd2e81f9bd8d94
|
||||
editor.toolbar.loadingDiff: 430386d6aae3570fd399a133e41c7372
|
||||
editor.toolbar.gitDiff: 51b46b6eac27ba484479246b875f76f5
|
||||
editor.toolbar.showDiff: 08d579de8d3abdcdfce0953a797362c6
|
||||
editor.toolbar.openAi: d2e93006570a66709c8ce0dc27082ef1
|
||||
editor.toolbar.closeAi: cd46973ef73076d612dff9903ce54498
|
||||
editor.toolbar.openTableOfContents: b733f053ea3fde4a9acd589ec7f58c10
|
||||
editor.toolbar.closeTableOfContents: d3292972a10edd1a06a627f3552f760a
|
||||
editor.toolbar.restoreArchived: 1bff5cf4943af64c05b2e9aeadccbc84
|
||||
editor.toolbar.archive: 63dc964c32e715217b49f8739d49636b
|
||||
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
|
||||
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
|
||||
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
|
||||
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
|
||||
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
|
||||
tableOfContents.title: f61d6c3e3733db355168b7e1aee6780b
|
||||
tableOfContents.close: d3292972a10edd1a06a627f3552f760a
|
||||
tableOfContents.empty: e4f02ad69cbbce1ec65d14215e3d5871
|
||||
tableOfContents.emptyNoNote: 046d95682b747a30ed8a7b0b1d581629
|
||||
tableOfContents.navLabel: 598b8ae10dfce818e73d3218daddbdae
|
||||
tableOfContents.untitledHeading: 93c2dde207965b383b7b22af005ebe4f
|
||||
tableOfContents.expandHeading: 6353ab44fe75f5dd935789bdab8551a0
|
||||
tableOfContents.collapseHeading: 040b4c102283028831ea78713235e478
|
||||
editor.codeBlock.copy: 8e477020fb3f7ab844b91ff1d7de8347
|
||||
editor.imageLightbox.title: 2c5a15c875bcdc2478c4a5cad044e10c
|
||||
editor.filename.rename: c31c2b468229232ad6287e734fe67d96
|
||||
editor.filename.renameToTitle: bff34a6a2dd1eb87c59403946679237f
|
||||
editor.filename.trigger: 4e9739c2f937c828bbf5d1f935fe7fb9
|
||||
editor.banner.archived: 7d69b3cb4cada18ae61811304f8fbcb5
|
||||
editor.banner.unarchive: 9fd02d2eb5ce348a74372eea202a0aec
|
||||
editor.banner.conflict: 384fc5a4b31c662b1e3426a7ef56441e
|
||||
editor.banner.keepMine: 544a2f2e8e09f93919f10e1920d1b69e
|
||||
editor.banner.keepMineTooltip: 9988cb86b051dd728e2d2f852f0283e4
|
||||
editor.banner.keepTheirs: 46131020af44cc6ec4bfa0a8ff39e044
|
||||
editor.banner.keepTheirsTooltip: 9e5b845c4459747d6ab0df6438ccfac4
|
||||
inspector.title.properties: 9fc2d28c05ed9eb1d75ba4465abf15a9
|
||||
inspector.title.propertiesShortcut: 427d1cbdc813cf54c5fac2508d38d407
|
||||
inspector.title.closePropertiesShortcut: 97b953e45042d4143dd19624dc345d29
|
||||
inspector.title.collidingProperties: bf71358d0af34696e88bea278eac046a
|
||||
inspector.title.collidingPropertiesAria: ce5c3569f304cd69b0f33a6fc185f49b
|
||||
inspector.empty.noNoteSelected: 046d95682b747a30ed8a7b0b1d581629
|
||||
inspector.empty.noProperties: 6864166e0f65d81b1eb474e41fde8822
|
||||
inspector.empty.initializeProperties: edf249d214b68f5afe8634c577b709c4
|
||||
inspector.empty.invalidProperties: 2ee2429c1ffbabcda9f57f91fb6c0b9d
|
||||
inspector.empty.fixInEditor: b86d4934630d68bc5333c0feb25e5f3f
|
||||
inspector.properties.addProperty: 9820ade036bf1bdf80c0b7da24a4ce0a
|
||||
inspector.properties.deleteProperty: f1d0ab48c8596108e949dfc90e13050b
|
||||
inspector.properties.none: 6adf97f83acf6453d4a6a4b1070f3754
|
||||
inspector.properties.missingType: 9179080e7bcc72408f3de7cb944ff400
|
||||
inspector.properties.missingTypeAria: 582c2c46117cff0534d13e7c8a45a3ba
|
||||
inspector.properties.searchTypes: 84c8d94846183a79444bf36667e8d7ea
|
||||
inspector.properties.noMatchingTypes: 9b64c31214171ba3b2d591743990b840
|
||||
inspector.properties.yes: 93cba07454f06a4a960172bbd6e2a435
|
||||
inspector.properties.no: bafd7322c6e97d25b6299b5d6fe8920b
|
||||
inspector.properties.pickDate: e8e91fbba934dc8adfd7e433f5193911
|
||||
inspector.properties.valuePlaceholder: 689202409e48743b914713f96d93947c
|
||||
inspector.properties.propertyName: 7e9982311d69d66d38809e5d85377a08
|
||||
inspector.relationship.add: ec211f7c20af43e742bf2570c3cb84f9
|
||||
inspector.relationship.addRelationship: d2b0cb45dcdbb00ee70db397c36f73ad
|
||||
inspector.relationship.name: 7ed3fccc071d6939eff211b1a6fd9696
|
||||
inspector.relationship.noteTitle: f72ea00f174785710f0369b23c53963c
|
||||
inspector.relationship.createAndOpen: 55bb53fea743c584c4024e7439c55bfe
|
||||
inspector.info.title: 4059b0251f66a18cb56f544728796875
|
||||
inspector.info.modified: 35e0c8c0b180c95d4e122e55ed62cc64
|
||||
inspector.info.created: 0eceeb45861f9585dd7a97a3e36f85c6
|
||||
inspector.info.words: 6f15b8d4b7287d60a8ea3d1c5cbadc84
|
||||
inspector.info.size: 6f6cb72d544962fa333e2e34ce64f719
|
||||
update.available: 992477975168b876be8e77ce2975e390
|
||||
update.checking: a07813cc05571f23ce8dd7039583d7da
|
||||
update.releaseNotes: 5dd03e8d039863e563e049be198c3fd3
|
||||
update.updateNow: 99b1054c0f320be9f109c877a5efd0b2
|
||||
update.dismiss: c8a59e7135a20b362f9c768b09454fdb
|
||||
update.downloading: 2c7fdcafb08f831420f661810f826549
|
||||
update.readyRestart: 4d7487b02d955a44d599189ec383e469
|
||||
update.restartNow: 2d9c2140c53daf05855033aaceeaa8fd
|
||||
status.update.check: eb1b4bb3667dd670210c7822badc2f1d
|
||||
status.zoom.reset: dbf36ab275e5fbd256590e65aa1ca9a3
|
||||
status.feedback.contribute: 96ab5025e38aef43fdb73c9830795264
|
||||
status.feedback.label: 9887a4451812854f0f1b6f669a874307
|
||||
status.theme.light: 4abf27a209985375949aaa426c7c7f3d
|
||||
status.theme.dark: 541be2a55a52b518b8e510c476c7cc95
|
||||
status.settings.open: bae08226d065231df6f01b08cd681c9b
|
||||
status.build.unknown: d250349dd489bdfeb0cb0750b8b3ff89
|
||||
status.vault.switch: 8a2ad262643c556ff0cfa99497aaa529
|
||||
status.vault.default: 5b70a213f150f01f0776fa9481ef2ddf
|
||||
status.vault.createEmpty: f37c03f236fbf1516222360a936249e8
|
||||
status.vault.openLocal: a367344e0429a12a5cc9a6cecbbfcde5
|
||||
status.vault.cloneGit: a7d0aef7c6237a36e54fd5a26e9c23fe
|
||||
status.vault.cloneGettingStarted: 9953a11a477b441976a626a9acf5d7df
|
||||
status.vault.notFound: 3119b7fa94c96209bca571b7c7ed0b7e
|
||||
status.vault.reloading: 893da50ef3a9e01d8a99ff5d3ceb55e9
|
||||
status.vault.reloadingTooltip: 4a884bd7d113797ad16f905f70742da8
|
||||
status.vault.remove: 7f3b4f76df23626170940dbc55c70728
|
||||
status.remote.noneConfigured: 18a4edd4e8d862ccb343937f45585fb1
|
||||
status.remote.inSync: a56f571b4df55d88fb06e61e343b3c91
|
||||
status.remote.aheadTitle: 1516f4b92671b83c17441b4cddf25a7a
|
||||
status.remote.behindTitle: 55ee20469bc46b7c61e7515fbfdc0b5d
|
||||
status.remote.ahead: 681557ace3a84e15060ffca1623b75e6
|
||||
status.remote.behind: 1825bb12c857a71861280e490e29debd
|
||||
status.remote.add: f292f8ef85219f0acde6b66eb9c9c0f4
|
||||
status.remote.none: ed3f2ebe0d847584fd62fc9e0db2df33
|
||||
status.remote.noneDescription: f47636010c21c88a6f81cc41a962b26c
|
||||
status.sync.syncing: b21888f3f83c224b3451901da243d00a
|
||||
status.sync.conflict: f1d4ac54357cc0932f385d56814ba7e4
|
||||
status.sync.failed: 85b08b086888f8ee2680ddd3398f3574
|
||||
status.sync.pullRequired: 1ea27a2d3048b078a18665b24e8dcd91
|
||||
status.sync.notSynced: d18d6927f7ffe96f72e18b4da12e306b
|
||||
status.sync.justNow: 7b9275ee2786ef410555041b92b36bb3
|
||||
status.sync.minutesAgo: b3ac76d1e2595531940035a8e2e2d13c
|
||||
status.sync.resolveConflicts: b9f44cc5ee01c964f004f16e3a2833ca
|
||||
status.sync.inProgress: 278d2bf6768809fbf6e97e7bb319a7c0
|
||||
status.sync.pullAndPush: e07c084a9e734a54bd079d5327a924e7
|
||||
status.sync.retry: ca43e18d4f930e8a3e6d403f31d30a39
|
||||
status.sync.now: c03fafa3a653240e6104348e1293bb88
|
||||
status.sync.synced: 5befab0dde764b6dd8b24a34dc30afa7
|
||||
status.sync.conflicts: d35cef9595a935771f5e8158f387227b
|
||||
status.sync.error: 902b0d55fddef6f8d651fe1035b7d4bd
|
||||
status.sync.status: 76590d5f9708692e3735891bdca40903
|
||||
status.sync.pull: 718f59718640c6506b3721fbc8bf3a4d
|
||||
status.conflict.count: 46293d20ed2071fb30762b71fdaa5894
|
||||
status.offline.title: 3bf45a7003646cc4f963810b0b7ac0f5
|
||||
status.offline.label: 8d9da4bc0e49a50e09ac9f7e56789d39
|
||||
status.changes.view: 6d60d24d8f475ddaef94bbbc58514b5a
|
||||
status.changes.label: c112bb3542e98308d12d5ecb10a67abc
|
||||
status.commit.local: 6474fe7e2ed525df3da3eb447943bfcd
|
||||
status.commit.push: a4aaf10ef20cf17a982bb5b6dad198e0
|
||||
status.commit.label: 59d5b10c3a447f036d85cb5ce524c96c
|
||||
status.commit.openOnGitHub: 445ad18680a573c2aab062c3273ae16a
|
||||
status.git.disabledTooltip: 6981fe5b4993e4b3299c774b185ecd70
|
||||
status.git.disabled: 7f822b0ee91b8922b0c41723da867bd1
|
||||
status.history.onlyGit: 7da5397c33ada1b73a81c5a449cbab16
|
||||
status.history.open: 802ec1fd97592612c19ebef7bebbb5c2
|
||||
status.history.label: 16d2b386b2034b9488996466aaae0b57
|
||||
status.mcp.notConnected: 7e8850f4a564088ced4f592996a39309
|
||||
status.mcp.unknown: 051ff29ff900592f75974d0ef554c2eb
|
||||
status.claude.missing: 89021856e6152467f54ed33faed55e2f
|
||||
status.claude.label: 38c66b824d5769e42c3866005296525b
|
||||
status.claude.install: 1f4688bc2a2acc268811c9edcec810a1
|
||||
status.ai.noAgents: 0876a176a6297e79366d0a2935851f81
|
||||
status.ai.noAgentsTooltip: 1bdc02ec2dd7f9701b4371ffd5770318
|
||||
status.ai.selectedMissing: a37f3dbc73725219e90d709fe0d3ad97
|
||||
status.ai.defaultAgent: 79650d57d1678e56c75dbbcba6ea87f7
|
||||
status.ai.defaultTarget: 65ea5bdb609833c4baffd2cbd0e86542
|
||||
status.ai.restoreDetails: 47a913b58ce40add6521bc93e807476f
|
||||
status.ai.withGuidance: 1eafd34810db6bba54dac7aa549b5182
|
||||
status.ai.active: 059e3a3167c2ab00f4ca872e82a48d98
|
||||
status.ai.unavailable: 0d3e6e3afef545710aa24ed2ea4990af
|
||||
status.ai.install: 349838fb1d851d3e2014b9fe39203275
|
||||
status.ai.installAgent: 78ac3395d977f8b86ca9a02f781f4af8
|
||||
status.ai.vaultGuidance: 7bb4644efe6ae7cec9993c8bd85f1519
|
||||
status.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2
|
||||
status.ai.openOptions: d387b1ab67586c7a2d83db8900a4dd8e
|
||||
status.ai.modelTargets: 8f3ceb96e088f8bdaecc236778401ac9
|
||||
status.ai.localChat: 215a24e3d38fb8b68deea6dad0142326
|
||||
status.ai.apiChat: 6dffd5b9997885efe701631cea5c470d
|
||||
pulse.title: 16d2b386b2034b9488996466aaae0b57
|
||||
pulse.today: 1dd1c5fb7f25cd41b291d43a89e3aefd
|
||||
pulse.yesterday: ebfe9ce86e6e9fb953aa7a25b59c1956
|
||||
pulse.commitCount: 17dc5f11868c946f55ff8164d318856d
|
||||
pulse.commitSingular: fffca4d67ea0a788813031b8bbc3b329
|
||||
pulse.commitPlural: a90814b38bbdfe717205f6d24183f6a7
|
||||
pulse.openOnGitHub: ddab0146d46f585f2ab36072c92a0048
|
||||
pulse.expandFiles: 53c567ce9f999dd937fc954c39853e81
|
||||
pulse.collapseFiles: 0ae74096d72faf840cd7d35635aa0cf9
|
||||
pulse.noActivity: cfbd8245c849f5f00b495d61ea14dfdf
|
||||
pulse.emptyDescription: b5ee4968b62908444166182fe8e593c1
|
||||
pulse.retry: 6327b4e59f58137083214a1fec358855
|
||||
pulse.loadingActivity: 26bf6a6bc2fd8148cd8753542a0ddf86
|
||||
pulse.loading: 5f02f05c744a0f875aebb8625ae1486f
|
||||
pulse.loadError: 63c17996fe219e6bd33b27d2b7ce0c96
|
||||
command.switchLanguage: 60f1a6760c7d1da0b1f7f6d0529608ee
|
||||
locale.itIT: 4be8e06d27bca7e1828f2fa9a49ca985
|
||||
locale.frFR: ad225f707802ba118c22987186dd38e8
|
||||
locale.deDE: 86bc3115eb4e9873ac96904a4a68e19e
|
||||
locale.ruRU: deba6920e70615401385fe1fb5a379ec
|
||||
locale.esES: cdbe021be1976335ab583a845edf7ed6
|
||||
locale.ptBR: 26d5352ead4a731f3d11eb2e85d0e297
|
||||
locale.ptPT: 71bfc0d79772120b52c1c0782450ff84
|
||||
locale.es419: edbf64ac382b82a54795fb409beb54be
|
||||
locale.zhCN: 1e81fc32fea0e9f3a978661e4b5e484d
|
||||
locale.zhTW: d0bb8c23f6e4f8c8037b6774ba912036
|
||||
locale.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e
|
||||
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb
|
||||
locale.vi: 7b80fae85640c16cdb0261bef0c27636
|
||||
locale.plPL: c730389bc8d99e59c867766babdd48b5
|
||||
33
lara.yaml
Normal file
33
lara.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: "1.0.0"
|
||||
|
||||
project:
|
||||
instruction: >
|
||||
Tolaria is a desktop knowledge-management app. Keep product names, CLI names,
|
||||
markdown wikilinks, frontmatter keys, file paths, and placeholders like
|
||||
{agent}, {zoom}, {language}, {name}, {label}, {file}, and {count} unchanged.
|
||||
|
||||
locales:
|
||||
source: en
|
||||
target:
|
||||
- it-IT
|
||||
- fr-FR
|
||||
- de-DE
|
||||
- ru-RU
|
||||
- es-ES
|
||||
- pt-BR
|
||||
- pt-PT
|
||||
- es-419
|
||||
- zh-CN
|
||||
- zh-TW
|
||||
- ja-JP
|
||||
- ko-KR
|
||||
- vi
|
||||
- pl-PL
|
||||
|
||||
files:
|
||||
json:
|
||||
include:
|
||||
- "src/lib/locales/[locale].json"
|
||||
exclude: []
|
||||
lockedKeys: []
|
||||
ignoredKeys: []
|
||||
@@ -2,9 +2,8 @@
|
||||
/**
|
||||
* Tolaria MCP Server — lightweight vault tools for AI agents.
|
||||
*
|
||||
* The agent has full shell access (bash, read, write, edit).
|
||||
* These MCP tools provide Tolaria-specific capabilities that
|
||||
* native tools cannot replace:
|
||||
* These MCP tools provide Tolaria-specific capabilities alongside each
|
||||
* app-managed agent's own Safe / Power User permission profile:
|
||||
*
|
||||
* - search_notes: full-text search across vault notes
|
||||
* - get_vault_context: vault structure overview (types, note count, folders)
|
||||
@@ -21,35 +20,78 @@ import {
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import WebSocket from 'ws'
|
||||
import { searchNotes, getNote, vaultContext } from './vault.js'
|
||||
import { requireVaultPath } from './vault-path.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const VAULT_PATH = requireVaultPath()
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
|
||||
|
||||
// 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).
|
||||
let uiSocket = null
|
||||
let reconnectTimer = null
|
||||
let shutdownStarted = false
|
||||
const RECONNECT_INTERVAL_MS = 3000
|
||||
|
||||
function connectUiBridge() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(WS_UI_URL)
|
||||
uiSocket = ws
|
||||
ws.on('open', () => {
|
||||
uiSocket = ws
|
||||
if (shutdownStarted) {
|
||||
closeUiSocket()
|
||||
return
|
||||
}
|
||||
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
|
||||
})
|
||||
ws.on('close', () => {
|
||||
uiSocket = null
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
if (uiSocket === ws) uiSocket = null
|
||||
scheduleUiReconnect()
|
||||
})
|
||||
ws.on('error', () => {
|
||||
// Silent — bridge may not be running yet, will retry
|
||||
})
|
||||
} catch {
|
||||
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
scheduleUiReconnect()
|
||||
}
|
||||
}
|
||||
connectUiBridge()
|
||||
|
||||
function scheduleUiReconnect() {
|
||||
if (shutdownStarted) return
|
||||
|
||||
clearUiReconnectTimer()
|
||||
reconnectTimer = setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
|
||||
reconnectTimer.unref?.()
|
||||
}
|
||||
|
||||
function clearUiReconnectTimer() {
|
||||
if (!reconnectTimer) return
|
||||
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
function closeUiSocket() {
|
||||
const socket = uiSocket
|
||||
uiSocket = null
|
||||
if (!socket) return
|
||||
|
||||
socket.removeAllListeners()
|
||||
socket.on('error', () => {})
|
||||
if (socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.terminate?.()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
socket.close()
|
||||
} catch {
|
||||
// Ignore close races during process teardown.
|
||||
}
|
||||
socket.terminate?.()
|
||||
}
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
|
||||
@@ -120,15 +162,6 @@ const TOOLS = [
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
search_notes: handleSearchNotes,
|
||||
get_vault_context: handleVaultContext,
|
||||
get_note: handleGetNote,
|
||||
open_note: handleOpenNote,
|
||||
highlight_editor: handleHighlightEditor,
|
||||
refresh_vault: handleRefreshVault,
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
const results = await searchNotes(VAULT_PATH, args.query, args.limit)
|
||||
const text = results.length === 0
|
||||
@@ -165,6 +198,25 @@ function handleRefreshVault(args) {
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
function callToolHandler(name, args) {
|
||||
switch (name) {
|
||||
case 'search_notes':
|
||||
return handleSearchNotes(args)
|
||||
case 'get_vault_context':
|
||||
return handleVaultContext()
|
||||
case 'get_note':
|
||||
return handleGetNote(args)
|
||||
case 'open_note':
|
||||
return handleOpenNote(args)
|
||||
case 'highlight_editor':
|
||||
return handleHighlightEditor(args)
|
||||
case 'refresh_vault':
|
||||
return handleRefreshVault(args)
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
@@ -178,12 +230,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
const handler = TOOL_HANDLERS[name]
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
try {
|
||||
return await handler(args)
|
||||
return await callToolHandler(name, args)
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
||||
@@ -192,10 +240,47 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function shutdown(exitCode = 0) {
|
||||
if (shutdownStarted) return
|
||||
|
||||
shutdownStarted = true
|
||||
clearUiReconnectTimer()
|
||||
closeUiSocket()
|
||||
|
||||
try {
|
||||
await server.close()
|
||||
} catch (error) {
|
||||
console.error(`[mcp] Error while closing server: ${error.message}`)
|
||||
}
|
||||
|
||||
process.exitCode = exitCode
|
||||
setImmediate(() => process.exit(exitCode))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport()
|
||||
server.onclose = () => {
|
||||
void shutdown(0)
|
||||
}
|
||||
process.stdin.once('end', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.stdin.once('close', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.once('SIGINT', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void shutdown(0)
|
||||
})
|
||||
|
||||
connectUiBridge()
|
||||
await server.connect(transport)
|
||||
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
void shutdown(1)
|
||||
})
|
||||
|
||||
32
mcp-server/package-lock.json
generated
32
mcp-server/package-lock.json
generated
@@ -14,9 +14,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"version": "1.19.13",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
|
||||
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -431,12 +431,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.2.tgz",
|
||||
"integrity": "sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -619,9 +619,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz",
|
||||
"integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==",
|
||||
"version": "4.12.14",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -670,9 +670,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -882,9 +882,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
|
||||
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -12,5 +12,11 @@
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.13",
|
||||
"express-rate-limit": "8.2.2",
|
||||
"hono": "4.12.14",
|
||||
"path-to-regexp": "8.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
import { describe, it, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs/promises'
|
||||
import { spawn } from 'node:child_process'
|
||||
import {
|
||||
mkdtemp, mkdir, open, rm,
|
||||
} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { requireVaultPath } from './vault-path.js'
|
||||
import { evaluateBridgeRequest } from './ws-bridge.js'
|
||||
|
||||
let tmpDir
|
||||
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
|
||||
const MCP_SERVER_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
|
||||
|
||||
await fs.mkdir(path.join(tmpDir, 'project'), { recursive: true })
|
||||
await fs.mkdir(path.join(tmpDir, 'note'), { recursive: true })
|
||||
await mkdir(path.join(tmpDir, 'project'), { recursive: true })
|
||||
await mkdir(path.join(tmpDir, 'note'), { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'project', 'test-project.md'), `---
|
||||
await writeTextFile(path.join(tmpDir, 'project', 'test-project.md'), `---
|
||||
title: Test Project
|
||||
is_a: Project
|
||||
status: Active
|
||||
@@ -28,7 +34,7 @@ status: Active
|
||||
This is a test project for the MCP server.
|
||||
`)
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'note', 'daily-log.md'), `---
|
||||
await writeTextFile(path.join(tmpDir, 'note', 'daily-log.md'), `---
|
||||
title: Daily Log
|
||||
is_a: Note
|
||||
---
|
||||
@@ -38,7 +44,7 @@ is_a: Note
|
||||
Today I worked on the MCP server implementation.
|
||||
`)
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'project', 'second-project.md'), `---
|
||||
await writeTextFile(path.join(tmpDir, 'project', 'second-project.md'), `---
|
||||
title: Second Project
|
||||
type: Project
|
||||
status: Draft
|
||||
@@ -53,7 +59,7 @@ Another project for testing list and context.
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('findMarkdownFiles', () => {
|
||||
@@ -191,17 +197,95 @@ describe('evaluateBridgeRequest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('requireVaultPath', () => {
|
||||
it('returns the explicit configured vault path', () => {
|
||||
assert.equal(
|
||||
requireVaultPath({ VAULT_PATH: '/tmp/Selected Vault' }),
|
||||
'/tmp/Selected Vault',
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects missing vault paths instead of falling back to ~/Laputa', () => {
|
||||
assert.throws(
|
||||
() => requireVaultPath({}),
|
||||
/VAULT_PATH is required/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stdio process lifecycle', () => {
|
||||
it('exits when the MCP client closes stdin', async () => {
|
||||
const child = spawn(process.execPath, ['index.js'], {
|
||||
cwd: MCP_SERVER_DIR,
|
||||
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk
|
||||
})
|
||||
|
||||
await sleep(200)
|
||||
child.stdin.end()
|
||||
|
||||
const exit = await waitForExit(child, 1_500)
|
||||
if (!exit) {
|
||||
child.kill()
|
||||
await waitForExit(child, 1_000)
|
||||
assert.fail(`MCP server stayed alive after stdin closed.\n${stderr}`)
|
||||
}
|
||||
|
||||
assert.equal(exit.signal, null)
|
||||
assert.equal(exit.code, 0, stderr)
|
||||
})
|
||||
})
|
||||
|
||||
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
|
||||
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideDir = await mkdtemp(path.join(os.tmpdir(), prefix))
|
||||
const outsideNote = path.join(outsideDir, 'outside.md')
|
||||
|
||||
try {
|
||||
await fs.writeFile(outsideNote, '# Outside\n')
|
||||
await writeTextFile(outsideNote, '# Outside\n')
|
||||
await assert.rejects(
|
||||
() => getNote(tmpDir, resolveNotePath(outsideNote)),
|
||||
{ message: ACTIVE_VAULT_ERROR },
|
||||
)
|
||||
} finally {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true })
|
||||
await rm(outsideDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function writeTextFile(filePath, content) {
|
||||
const handle = await open(filePath, 'w')
|
||||
try {
|
||||
await handle.writeFile(content, 'utf-8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function waitForExit(child, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}, timeoutMs)
|
||||
|
||||
child.once('exit', onExit)
|
||||
|
||||
function onExit(code, signal) {
|
||||
cleanup()
|
||||
resolve({ code, signal })
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timer)
|
||||
child.off('exit', onExit)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
7
mcp-server/vault-path.js
Normal file
7
mcp-server/vault-path.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export function requireVaultPath(env = process.env) {
|
||||
const vaultPath = env.VAULT_PATH?.trim()
|
||||
if (!vaultPath) {
|
||||
throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.')
|
||||
}
|
||||
return vaultPath
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Vault operations — read-only helpers for Laputa markdown vault.
|
||||
* Write operations are handled by the agent's native bash/write/edit tools.
|
||||
* 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.
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import { open, opendir, realpath } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import matter from 'gray-matter'
|
||||
|
||||
@@ -15,17 +16,17 @@ const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
|
||||
*/
|
||||
export async function findMarkdownFiles(dir) {
|
||||
const results = []
|
||||
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
const items = await opendir(dir)
|
||||
for await (const item of items) {
|
||||
await collectMarkdownFile(results, dir, item)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function resolveVaultNotePath(vaultPath, notePath) {
|
||||
const vaultRoot = await fs.realpath(vaultPath)
|
||||
const vaultRoot = await realpath(vaultPath)
|
||||
const requestedPath = resolveRequestedNotePath(vaultRoot, notePath)
|
||||
const noteRealPath = await fs.realpath(requestedPath)
|
||||
const noteRealPath = await realpath(requestedPath)
|
||||
const relativePath = path.relative(vaultRoot, noteRealPath)
|
||||
|
||||
if (!isVaultRelativePath(relativePath)) {
|
||||
@@ -50,7 +51,7 @@ export async function getNote(vaultPath, notePath) {
|
||||
noteRealPath,
|
||||
relativePath,
|
||||
} = await resolveVaultNotePath(vaultPath, notePath)
|
||||
const raw = await fs.readFile(noteRealPath, 'utf-8')
|
||||
const raw = await readUtf8File(noteRealPath)
|
||||
const parsed = matter(raw)
|
||||
return {
|
||||
path: relativePath,
|
||||
@@ -73,7 +74,7 @@ export async function searchNotes(vaultPath, query, limit = 10) {
|
||||
|
||||
for (const filePath of files) {
|
||||
if (results.length >= limit) break
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const content = await readUtf8File(filePath)
|
||||
const filename = path.basename(filePath, '.md')
|
||||
const titleMatch = extractTitle(content, filename)
|
||||
if (!matchesSearchQuery(titleMatch, content, q)) continue
|
||||
@@ -125,7 +126,8 @@ export async function vaultContext(vaultPath) {
|
||||
async function collectMarkdownFile(results, dir, item) {
|
||||
if (item.name.startsWith('.')) return
|
||||
|
||||
const full = path.join(dir, item.name)
|
||||
const full = resolveInside(dir, item.name)
|
||||
if (!full) return
|
||||
if (item.isDirectory()) {
|
||||
results.push(...await findMarkdownFiles(full))
|
||||
return
|
||||
@@ -138,7 +140,16 @@ async function collectMarkdownFile(results, dir, item) {
|
||||
|
||||
function resolveRequestedNotePath(vaultRoot, notePath) {
|
||||
if (path.isAbsolute(notePath)) return notePath
|
||||
return path.resolve(vaultRoot, notePath)
|
||||
const resolved = resolveInside(vaultRoot, notePath)
|
||||
if (!resolved) throw new Error(ACTIVE_VAULT_ERROR)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function resolveInside(root, target) {
|
||||
const resolved = path.resolve(root, target)
|
||||
const relative = path.relative(root, resolved)
|
||||
if (isVaultRelativePath(relative)) return resolved
|
||||
return null
|
||||
}
|
||||
|
||||
function isVaultRelativePath(relativePath) {
|
||||
@@ -150,11 +161,11 @@ function matchesSearchQuery(title, content, query) {
|
||||
}
|
||||
|
||||
async function readVaultContextNote(vaultPath, filePath) {
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const raw = await readUtf8File(filePath)
|
||||
const parsed = matter(raw)
|
||||
const rel = path.relative(vaultPath, filePath)
|
||||
const topFolder = extractTopFolder(rel)
|
||||
const stat = await fs.stat(filePath)
|
||||
const stat = await statFile(filePath)
|
||||
const type = parsed.data.type || parsed.data.is_a || null
|
||||
|
||||
return {
|
||||
@@ -178,8 +189,8 @@ async function readConfigFiles(vaultPath) {
|
||||
const configFiles = {}
|
||||
|
||||
try {
|
||||
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
|
||||
configFiles.agents = await fs.readFile(agentsPath, 'utf-8')
|
||||
const agentsPath = resolveInside(vaultPath, 'config/agents.md')
|
||||
if (agentsPath) configFiles.agents = await readUtf8File(agentsPath)
|
||||
} catch {
|
||||
// config/agents.md may not exist yet
|
||||
}
|
||||
@@ -187,6 +198,24 @@ async function readConfigFiles(vaultPath) {
|
||||
return configFiles
|
||||
}
|
||||
|
||||
async function readUtf8File(filePath) {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
return await handle.readFile('utf-8')
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function statFile(filePath) {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
return await handle.stat()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract title from markdown content (first H1 or frontmatter title).
|
||||
* @param {string} content
|
||||
|
||||
@@ -24,8 +24,8 @@ import { WebSocketServer } from 'ws'
|
||||
import {
|
||||
getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
import { requireVaultPath } from './vault-path.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
const LOOPBACK_HOST = 'localhost'
|
||||
@@ -37,6 +37,13 @@ const TRUSTED_UI_ORIGINS = new Set([
|
||||
|
||||
/** @type {WebSocketServer | null} */
|
||||
let uiBridge = null
|
||||
let vaultPath = null
|
||||
const UNKNOWN_TOOL = Symbol('unknown tool')
|
||||
|
||||
function activeVaultPath() {
|
||||
vaultPath ??= requireVaultPath()
|
||||
return vaultPath
|
||||
}
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiBridge) return
|
||||
@@ -47,30 +54,65 @@ function broadcastUiAction(action, payload) {
|
||||
}
|
||||
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
vault_context: () => vaultContext(VAULT_PATH),
|
||||
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
|
||||
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
|
||||
async function readNoteTool(args) {
|
||||
const note = await getNote(activeVaultPath(), args.path)
|
||||
return { content: note.content, frontmatter: note.frontmatter }
|
||||
}
|
||||
|
||||
function uiOpenNoteTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_note', { path: args.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function uiOpenTabTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function highlightTool(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function uiSetFilterTool(args) {
|
||||
broadcastUiAction('set_filter', { filterType: args.type })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function refreshVaultTool(args) {
|
||||
broadcastUiAction('vault_changed', { path: args?.path })
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const TOOL_EXECUTORS = [
|
||||
['open_note', readNoteTool],
|
||||
['read_note', readNoteTool],
|
||||
['search_notes', (args) => searchNotes(activeVaultPath(), args.query, args.limit)],
|
||||
['vault_context', () => vaultContext(activeVaultPath())],
|
||||
['ui_open_note', uiOpenNoteTool],
|
||||
['ui_open_tab', uiOpenTabTool],
|
||||
['ui_highlight', highlightTool],
|
||||
['highlight_editor', highlightTool],
|
||||
['ui_set_filter', uiSetFilterTool],
|
||||
['refresh_vault', refreshVaultTool],
|
||||
]
|
||||
|
||||
function callToolHandler(tool, args) {
|
||||
const executor = TOOL_EXECUTORS.find(([name]) => name === tool)?.[1]
|
||||
return executor ? executor(args) : UNKNOWN_TOOL
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
const msg = JSON.parse(data)
|
||||
const { id, tool, args } = msg
|
||||
|
||||
const handler = TOOL_HANDLERS[tool]
|
||||
if (!handler) {
|
||||
return { id, error: `Unknown tool: ${tool}` }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(args || {})
|
||||
const result = await callToolHandler(tool, args || {})
|
||||
if (result === UNKNOWN_TOOL) {
|
||||
return { id, error: `Unknown tool: ${tool}` }
|
||||
}
|
||||
return { id, result }
|
||||
} catch (err) {
|
||||
return { id, error: err.message }
|
||||
@@ -164,6 +206,7 @@ export function startUiBridge(port = WS_UI_PORT) {
|
||||
}
|
||||
|
||||
export function startBridge(port = WS_PORT) {
|
||||
const currentVaultPath = activeVaultPath()
|
||||
const wss = new WebSocketServer({
|
||||
port,
|
||||
host: LOOPBACK_HOST,
|
||||
@@ -171,7 +214,7 @@ export function startBridge(port = WS_PORT) {
|
||||
})
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`)
|
||||
console.error(`[ws-bridge] Client connected (vault: ${currentVaultPath})`)
|
||||
|
||||
ws.on('message', async (raw) => {
|
||||
try {
|
||||
@@ -192,5 +235,11 @@ export function startBridge(port = WS_PORT) {
|
||||
// Run directly if invoked as main module
|
||||
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
|
||||
if (isMain) {
|
||||
startUiBridge().then(() => startBridge())
|
||||
try {
|
||||
activeVaultPath()
|
||||
startUiBridge().then(() => startBridge())
|
||||
} catch (err) {
|
||||
console.error(`[ws-bridge] ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
25
package.json
25
package.json
@@ -9,12 +9,15 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
|
||||
"lint": "eslint .",
|
||||
"l10n:translate": "lara-cli translate",
|
||||
"l10n:translate:force": "lara-cli translate --force",
|
||||
"l10n:validate": "node scripts/validate-locales.mjs",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/autosave-low-end-typing.spec.ts tests/smoke/create-note-backing-file.spec.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/frontmatter-date-picker.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/missing-active-vault-recovery.spec.ts tests/smoke/missing-string-metadata-open-note.spec.ts tests/smoke/multibyte-search-snippet.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/pull-refresh-open-note.spec.ts tests/smoke/type-derived-properties.spec.ts tests/smoke/vault-loading-skeleton.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
@@ -52,11 +55,14 @@
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||
"@tldraw/assets": "4.5.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "3.4.2",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"posthog-js": "^1.363.5",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.0",
|
||||
@@ -66,8 +72,10 @@
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"safe-regex2": "5.1.1",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tldraw": "^4.5.10",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unicode-emoji-json": "^0.8.0"
|
||||
},
|
||||
@@ -77,6 +85,7 @@
|
||||
"@tauri-apps/cli": "^2.10.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@translated/lara-cli": "^1.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -93,8 +102,20 @@
|
||||
"jsdom": "^28.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.2",
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@hono/node-server": "1.19.13",
|
||||
"express-rate-limit": "8.2.2",
|
||||
"hono": "4.12.14",
|
||||
"path-to-regexp": "8.4.0",
|
||||
"picomatch": "4.0.4",
|
||||
"postcss": "8.5.10",
|
||||
"protobufjs": "7.5.6",
|
||||
"rollup": "4.59.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,11 +1,24 @@
|
||||
diff --git a/dist/blocknote-react.js b/dist/blocknote-react.js
|
||||
index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3f9f0ea59 100644
|
||||
index d4d36cd4dbb5370c3b7431ffeb577e4870b39825..5a6c325bf0ff9a23f2023169d7fcf6319df91544 100644
|
||||
--- a/dist/blocknote-react.js
|
||||
+++ b/dist/blocknote-react.js
|
||||
@@ -157,6 +157,16 @@ function so(e) {
|
||||
const n = "getBoundingClientRect" in e ? () => e.getBoundingClientRect() : () => e.element.getBoundingClientRect();
|
||||
return () => "element" in e && (e.cacheMountedBoundingClientRect ?? !0) ? (e.element.isConnected && (t = n()), t) : n();
|
||||
}
|
||||
@@ -154,8 +154,26 @@ const co = (e) => {
|
||||
};
|
||||
function so(e) {
|
||||
let t = new DOMRect();
|
||||
- const n = "getBoundingClientRect" in e ? () => e.getBoundingClientRect() : () => e.element.getBoundingClientRect();
|
||||
- return () => "element" in e && (e.cacheMountedBoundingClientRect ?? !0) ? (e.element.isConnected && (t = n()), t) : n();
|
||||
+ const n = () => "getBoundingClientRect" in e ? e.getBoundingClientRect() : e.element instanceof Element ? e.element.getBoundingClientRect() : t;
|
||||
+ return () => {
|
||||
+ if (!("element" in e) || !(e.cacheMountedBoundingClientRect ?? !0))
|
||||
+ return n();
|
||||
+ if (!(e.element instanceof Element))
|
||||
+ return n();
|
||||
+ if (e.element.isConnected)
|
||||
+ t = n();
|
||||
+ return t;
|
||||
+ };
|
||||
+}
|
||||
+function __bnSafeDomAtPos(e, t) {
|
||||
+ const n = e.prosemirrorView;
|
||||
+ if (!n || n.isDestroyed)
|
||||
@@ -15,11 +28,10 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
|
||||
+ } catch {
|
||||
+ return null;
|
||||
+ }
|
||||
+}
|
||||
}
|
||||
const z = (e) => {
|
||||
var h, b, p;
|
||||
const { refs: t, floatingStyles: n, context: o } = Ze({
|
||||
@@ -216,9 +226,7 @@ const Lt = (e) => {
|
||||
@@ -216,9 +234,7 @@ const z = (e) => {
|
||||
const s = Ue(t, c.doc);
|
||||
if (!s)
|
||||
return;
|
||||
@@ -30,7 +42,39 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
|
||||
if (a instanceof Element)
|
||||
return {
|
||||
element: a
|
||||
@@ -3306,14 +3314,15 @@ const pi = (e) => {
|
||||
@@ -2499,7 +2515,14 @@ function Kr(e) {
|
||||
columns: d
|
||||
} = e, m = H(
|
||||
(C) => {
|
||||
- a(), s(), u == null || u(C);
|
||||
+ a();
|
||||
+ try {
|
||||
+ s();
|
||||
+ } catch (x) {
|
||||
+ console.warn("Ignored stale suggestion menu query cleanup:", x);
|
||||
+ return;
|
||||
+ }
|
||||
+ u == null || u(C);
|
||||
},
|
||||
[u, a, s]
|
||||
), { items: g, usedQuery: f, loadingState: h } = Yt(
|
||||
@@ -2734,7 +2757,14 @@ function ti(e) {
|
||||
onItemClick: u
|
||||
} = e, d = H(
|
||||
(p) => {
|
||||
- a(), s(), u == null || u(p);
|
||||
+ a();
|
||||
+ try {
|
||||
+ s();
|
||||
+ } catch (C) {
|
||||
+ console.warn("Ignored stale suggestion menu query cleanup:", C);
|
||||
+ return;
|
||||
+ }
|
||||
+ u == null || u(p);
|
||||
},
|
||||
[u, a, s]
|
||||
), { items: m, usedQuery: g, loadingState: f } = Yt(
|
||||
@@ -3306,14 +3336,15 @@ const ii = (e, t = 0.3) => {
|
||||
);
|
||||
if (!m)
|
||||
return {};
|
||||
@@ -49,7 +93,7 @@ index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3
|
||||
return p instanceof Element ? (d.cellReference = { element: p }, d.rowReference = {
|
||||
element: f,
|
||||
getBoundingClientRect: () => {
|
||||
@@ -4371,7 +4380,7 @@ const El = (e) => {
|
||||
@@ -4371,7 +4402,7 @@ const zi = (e) => {
|
||||
const a = Ue(t, c.prosemirrorState.doc);
|
||||
if (!a)
|
||||
return;
|
||||
|
||||
@@ -2,7 +2,9 @@ import { defineConfig } from '@playwright/test'
|
||||
|
||||
const baseURL = process.env.BASE_URL || 'http://127.0.0.1:41741'
|
||||
const port = new URL(baseURL).port || '41741'
|
||||
const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER === '1'
|
||||
const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER
|
||||
? process.env.PLAYWRIGHT_REUSE_SERVER === '1'
|
||||
: process.env.CI !== 'true'
|
||||
const claudeCodeOnboardingStorageState = {
|
||||
cookies: [],
|
||||
origins: [
|
||||
@@ -28,7 +30,7 @@ export default defineConfig({
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: `pnpm dev --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
command: `node scripts/playwright-smoke-server.mjs ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer,
|
||||
timeout: 30_000,
|
||||
|
||||
2657
pnpm-lock.yaml
generated
2657
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
18
release-notes/stable-v2026.5.2.md
Normal file
18
release-notes/stable-v2026.5.2.md
Normal file
@@ -0,0 +1,18 @@
|
||||
## New Features
|
||||
|
||||
- 📋 **Paste Without Formatting** — Paste copied text as plain content without bringing unwanted styling into a note.
|
||||
- 🇵🇱 **Polish Language Support** — Use Tolaria with a new Polish interface translation.
|
||||
- 🧭 **Refined Titlebar Navigation** — Navigate with clearer titlebar controls that feel more native on desktop.
|
||||
|
||||
## Improvements
|
||||
|
||||
- ⚡ **Faster Note Loading** — Switch between notes more smoothly by reusing cached note content and parsed editor blocks.
|
||||
- 🗂️ **Cleaner Sidebar and Menus** — Scan folders, sidebar sections, slash commands, icon choices, and release tabs with cleaner spacing and styling.
|
||||
- 🖥️ **Better Cross-Platform Window Controls** — Use macOS and Linux window controls that better match each platform.
|
||||
- ☀️ **Improved Light Mode Editing** — Read code blocks more comfortably when Tolaria is using the light theme.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- This release also improves editor reliability around block dragging, table handles, pasted Markdown, stale side-menu actions, and unrelated vault refreshes.
|
||||
- Vault, type, and frontmatter handling were hardened to prevent freezes, filename collisions, parse failures, and stale saved-view deletes.
|
||||
- Startup, Linux AppImage, release CI, and AI/Codex integration fixes are included in the full commit list.
|
||||
20
release-notes/v2026-05-04.md
Normal file
20
release-notes/v2026-05-04.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## New Features
|
||||
|
||||
- 🤖 **Direct AI Model Providers** — Use OpenAI, Anthropic, Gemini, OpenRouter, Ollama, LM Studio, or a custom OpenAI-compatible endpoint directly from Tolaria.
|
||||
- 🖊️ **Markdown Whiteboards** — Create and edit tldraw-powered whiteboards that live as durable Markdown files in your vault.
|
||||
- 🧭 **Table of Contents Panel** — Navigate long notes with a lazy, outline-style panel generated from the current document headings.
|
||||
- 📄 **Readable Release Notes** — View stable releases through curated notes written for users instead of raw commit lists.
|
||||
|
||||
## Improvements
|
||||
|
||||
- 🧩 **Cleaner AI Settings** — Configure provider secrets, local models, and AI targets through a more focused settings experience.
|
||||
- 💬 **Better Agent Context Handling** — Large AI agent contexts are compacted more safely, and Codex MCP tools are exposed more reliably.
|
||||
- 🧱 **Improved Editor Blocks** — Code blocks can be copied more easily, block controls feel steadier, and rich exports avoid unsupported browser APIs.
|
||||
- 🗂️ **Smarter Note and Type Handling** — Renames, type changes, frontmatter placeholders, and path identity now behave more consistently across vault flows.
|
||||
- 🌍 **Better International Editing** — RTL text direction, IME composition, Korean sidebar copy, and unicode git paths all received focused polish.
|
||||
|
||||
## Stability and Fixes
|
||||
|
||||
- Embedded diagrams, tldraw whiteboard assets, stale checklist toggles, and stale note open or rename flows were hardened to avoid broken editor states.
|
||||
- Vault recovery, missing active vault handling, fresh-note heading paste, and same-path type collisions now fail more gracefully.
|
||||
- Linux AppImage startup, MCP resource discovery, release CI, Codacy security findings, and AI provider runtime paths all received stability fixes.
|
||||
@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'
|
||||
|
||||
import {
|
||||
buildStableDownloadRedirectPage,
|
||||
resolveStableDmgUrl,
|
||||
resolveStableDownloadTargets,
|
||||
} from '../src/utils/releaseDownloadPage'
|
||||
|
||||
function getArg(flag: string): string {
|
||||
@@ -30,8 +30,8 @@ const releasesJsonPath = resolve(getArg('--releases-json'))
|
||||
const outputFilePath = resolve(getArg('--output-file'))
|
||||
const latestPayload = readLatestReleasePayload(latestJsonPath)
|
||||
const releasesPayload = readLatestReleasePayload(releasesJsonPath)
|
||||
const dmgUrl = resolveStableDmgUrl(latestPayload, releasesPayload)
|
||||
const html = buildStableDownloadRedirectPage(dmgUrl)
|
||||
const downloads = resolveStableDownloadTargets(latestPayload, releasesPayload)
|
||||
const html = buildStableDownloadRedirectPage(downloads)
|
||||
|
||||
mkdirSync(dirname(outputFilePath), { recursive: true })
|
||||
writeFileSync(outputFilePath, html)
|
||||
|
||||
32
scripts/playwright-smoke-server.mjs
Normal file
32
scripts/playwright-smoke-server.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const port = process.argv[2] ?? process.env.PORT ?? '41741'
|
||||
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['dev', '--host', '127.0.0.1', '--port', port, '--strictPort'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
},
|
||||
)
|
||||
|
||||
function forwardSignal(signal) {
|
||||
if (child.killed) return
|
||||
child.kill(signal)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => forwardSignal('SIGINT'))
|
||||
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
@@ -12,6 +12,9 @@ const forwardedArgs = process.argv.slice(2)
|
||||
const hasFileParallelismOverride = forwardedArgs.some((arg) =>
|
||||
arg === '--fileParallelism' || arg === '--no-file-parallelism'
|
||||
)
|
||||
const hasMaxWorkersOverride = forwardedArgs.some((arg) =>
|
||||
arg === '--maxWorkers' || arg.startsWith('--maxWorkers=')
|
||||
)
|
||||
const maxAttempts = 2
|
||||
|
||||
const packageManagerExec = process.env.npm_execpath
|
||||
@@ -19,6 +22,9 @@ const command = packageManagerExec ? process.execPath : 'pnpm'
|
||||
const baseCommandArgs = packageManagerExec
|
||||
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage']
|
||||
: ['exec', 'vitest', 'run', '--coverage']
|
||||
const clearCacheCommandArgs = packageManagerExec
|
||||
? [packageManagerExec, 'exec', 'vitest', '--clearCache']
|
||||
: ['exec', 'vitest', '--clearCache']
|
||||
|
||||
function isKnownVitestInternalStateFlake(output) {
|
||||
return output.includes('Vitest failed to access its internal state.')
|
||||
@@ -39,13 +45,15 @@ async function runCoverageAttempt(attempt) {
|
||||
await mkdir(runCoverageDir, { recursive: true })
|
||||
// Vitest writes per-worker coverage shards under reportsDirectory/.tmp.
|
||||
await mkdir(runCoverageTempDir, { recursive: true })
|
||||
await clearVitestCache()
|
||||
|
||||
const commandArgs = [
|
||||
...baseCommandArgs,
|
||||
// Vitest 4.0.18 occasionally crashes during coverage worker teardown
|
||||
// after all files pass, so serialize file execution unless a caller
|
||||
// explicitly opts into a different file-parallelism mode.
|
||||
...(hasFileParallelismOverride ? [] : ['--no-file-parallelism']),
|
||||
// Keep coverage fast enough for CI while avoiding the unbounded worker
|
||||
// contention that makes a few DOM-heavy suites time out under full
|
||||
// file parallelism. Callers can still opt into serial or wider runs.
|
||||
...(hasFileParallelismOverride ? [] : ['--fileParallelism']),
|
||||
...(hasMaxWorkersOverride ? [] : ['--maxWorkers=4']),
|
||||
`--coverage.reportsDirectory=${runCoverageDir}`,
|
||||
...forwardedArgs,
|
||||
]
|
||||
@@ -91,6 +99,30 @@ async function runCoverageAttempt(attempt) {
|
||||
}
|
||||
}
|
||||
|
||||
async function clearVitestCache() {
|
||||
const exitCode = await new Promise((resolveExit, rejectExit) => {
|
||||
const child = spawn(command, clearCacheCommandArgs, {
|
||||
cwd: rootDir,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('error', rejectExit)
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
rejectExit(new Error(`Vitest cache clear exited via signal: ${signal}`))
|
||||
return
|
||||
}
|
||||
|
||||
resolveExit(code ?? 1)
|
||||
})
|
||||
})
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Vitest cache clear failed with exit code ${exitCode}`)
|
||||
}
|
||||
}
|
||||
|
||||
let finalRun = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
*/
|
||||
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import {
|
||||
closeSync,
|
||||
createReadStream,
|
||||
fstatSync,
|
||||
openSync,
|
||||
opendirSync,
|
||||
readFileSync,
|
||||
} from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import matter from 'gray-matter'
|
||||
@@ -16,8 +23,71 @@ const REPO_DIR = path.resolve(__dirname, '..')
|
||||
const PORT = 5173
|
||||
|
||||
function isAllowedPath(p) {
|
||||
const resolved = path.resolve(p)
|
||||
return resolved.startsWith(REPO_DIR)
|
||||
return isInsideRelativePath(path.relative(REPO_DIR, p))
|
||||
}
|
||||
|
||||
function isInsideRelativePath(relative) {
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
}
|
||||
|
||||
function resolveInside(root, target) {
|
||||
const normalizedTarget = path.normalize(target)
|
||||
if (path.isAbsolute(normalizedTarget)) return null
|
||||
const candidate = path.normalize(`${root}${path.sep}${normalizedTarget}`)
|
||||
return isInsideRelativePath(path.relative(root, candidate)) ? candidate : null
|
||||
}
|
||||
|
||||
function readUtf8File(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return readFileSync(fd, 'utf-8')
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function pathStats(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return fstatSync(fd)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function pathExists(filePath) {
|
||||
try {
|
||||
pathStats(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function directoryEntries(dir) {
|
||||
const directory = opendirSync(dir)
|
||||
try {
|
||||
const entries = []
|
||||
let entry = directory.readSync()
|
||||
while (entry) {
|
||||
entries.push(entry)
|
||||
entry = directory.readSync()
|
||||
}
|
||||
return entries
|
||||
} finally {
|
||||
directory.closeSync()
|
||||
}
|
||||
}
|
||||
|
||||
function streamFile(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
return createReadStream(null, { fd, autoClose: true })
|
||||
}
|
||||
|
||||
function staticAssetPath(url) {
|
||||
const pathname = new URL(url, 'http://localhost').pathname
|
||||
const requested = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '')
|
||||
return resolveInside(DIST_DIR, requested) ?? path.normalize(`${DIST_DIR}${path.sep}index.html`)
|
||||
}
|
||||
|
||||
const MIME = {
|
||||
@@ -34,8 +104,9 @@ const MIME = {
|
||||
function findMarkdownFiles(dir) {
|
||||
const results = []
|
||||
try {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
for (const entry of directoryEntries(dir)) {
|
||||
const full = resolveInside(dir, entry.name)
|
||||
if (!full) continue
|
||||
if (entry.isDirectory()) results.push(...findMarkdownFiles(full))
|
||||
else if (entry.name.endsWith('.md')) results.push(full)
|
||||
}
|
||||
@@ -51,9 +122,9 @@ function extractWikiLinks(value) {
|
||||
|
||||
function parseMarkdownFile(filePath) {
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const raw = readUtf8File(filePath)
|
||||
const { data: fm, content } = matter(raw)
|
||||
const stat = fs.statSync(filePath)
|
||||
const stat = pathStats(filePath)
|
||||
|
||||
const DEDICATED = new Set(['aliases','Is A','Belongs to','Related to','Status','Owner','Cadence','Created at'])
|
||||
const relationships = {}
|
||||
@@ -98,7 +169,7 @@ function serveVaultApi(url, res) {
|
||||
|
||||
if (params.pathname === '/api/vault/list') {
|
||||
const dir = params.searchParams.get('path')
|
||||
if (!dir || !isAllowedPath(dir) || !fs.existsSync(dir)) {
|
||||
if (!dir || !isAllowedPath(dir) || !pathExists(dir)) {
|
||||
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
||||
}
|
||||
const entries = findMarkdownFiles(dir).map(parseMarkdownFile).filter(Boolean)
|
||||
@@ -109,22 +180,22 @@ function serveVaultApi(url, res) {
|
||||
|
||||
if (params.pathname === '/api/vault/content') {
|
||||
const file = params.searchParams.get('path')
|
||||
if (!file || !isAllowedPath(file) || !fs.existsSync(file)) {
|
||||
if (!file || !isAllowedPath(file) || !pathExists(file)) {
|
||||
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ content: fs.readFileSync(file, 'utf-8') }))
|
||||
res.end(JSON.stringify({ content: readUtf8File(file) }))
|
||||
return true
|
||||
}
|
||||
|
||||
if (params.pathname === '/api/vault/all-content') {
|
||||
const dir = params.searchParams.get('path')
|
||||
if (!dir || !isAllowedPath(dir) || !fs.existsSync(dir)) {
|
||||
if (!dir || !isAllowedPath(dir) || !pathExists(dir)) {
|
||||
res.writeHead(400); res.end(JSON.stringify({ error: 'bad path' })); return true
|
||||
}
|
||||
const map = {}
|
||||
for (const f of findMarkdownFiles(dir)) {
|
||||
try { map[f] = fs.readFileSync(f, 'utf-8') } catch {}
|
||||
try { map[f] = readUtf8File(f) } catch {}
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(map))
|
||||
@@ -146,13 +217,13 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
|
||||
// Static files
|
||||
let filePath = path.join(DIST_DIR, url === '/' ? 'index.html' : url)
|
||||
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(DIST_DIR, 'index.html') // SPA fallback
|
||||
let filePath = staticAssetPath(url)
|
||||
if (!pathExists(filePath) || pathStats(filePath).isDirectory()) {
|
||||
filePath = path.normalize(`${DIST_DIR}${path.sep}index.html`) // SPA fallback
|
||||
}
|
||||
const ext = path.extname(filePath)
|
||||
res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' })
|
||||
fs.createReadStream(filePath).pipe(res)
|
||||
streamFile(filePath).pipe(res)
|
||||
})
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
|
||||
149
scripts/validate-locales.mjs
Normal file
149
scripts/validate-locales.mjs
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
closeSync, fstatSync, openSync, opendirSync, readFileSync,
|
||||
} from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const localesDir = path.join(root, 'src/lib/locales')
|
||||
const sourcePath = path.join(localesDir, 'en.json')
|
||||
|
||||
function readCatalog(filePath) {
|
||||
return JSON.parse(readUtf8File(filePath))
|
||||
}
|
||||
|
||||
function readUtf8File(filePath) {
|
||||
const fd = openSync(filePath, 'r')
|
||||
try {
|
||||
return readFileSync(fd, 'utf8')
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function directoryFiles(dirPath) {
|
||||
const dir = opendirSync(dirPath)
|
||||
try {
|
||||
const files = []
|
||||
let entry = dir.readSync()
|
||||
while (entry) {
|
||||
if (entry.isFile()) files.push(entry.name)
|
||||
entry = dir.readSync()
|
||||
}
|
||||
return files
|
||||
} finally {
|
||||
dir.closeSync()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDirectory(dirPath) {
|
||||
const fd = openSync(dirPath, 'r')
|
||||
try {
|
||||
if (!fstatSync(fd).isDirectory()) {
|
||||
throw new Error(`${dirPath} is not a directory`)
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function isFlatObject(value) {
|
||||
if (!value) return false
|
||||
if (typeof value !== 'object') return false
|
||||
return !Array.isArray(value)
|
||||
}
|
||||
|
||||
function assertFlatStringCatalog(locale, catalog) {
|
||||
if (!isFlatObject(catalog)) {
|
||||
throw new Error(`${locale}: expected a flat object of translation keys`)
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(catalog)) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${locale}: key "${key}" must map to a string`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function missingKeys(sourceKeys, localeKeys) {
|
||||
const localeKeySet = new Set(localeKeys)
|
||||
return sourceKeys.filter((key) => !localeKeySet.has(key))
|
||||
}
|
||||
|
||||
function extraKeys(sourceKeys, localeKeys) {
|
||||
const sourceKeySet = new Set(sourceKeys)
|
||||
return localeKeys.filter((key) => !sourceKeySet.has(key))
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return Array.from(value.matchAll(/\{(\w+)\}/g), (match) => match[1]).sort()
|
||||
}
|
||||
|
||||
function sameValues(left, right) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function formatValues(values) {
|
||||
return values.length === 0 ? 'none' : values.join(', ')
|
||||
}
|
||||
|
||||
function placeholderIssues(locale, sourceCatalog, catalog) {
|
||||
const issues = []
|
||||
|
||||
for (const [key, sourceValue] of Object.entries(sourceCatalog)) {
|
||||
if (!(key in catalog)) continue
|
||||
|
||||
const sourcePlaceholders = placeholders(sourceValue)
|
||||
const localePlaceholders = placeholders(catalog[key])
|
||||
if (sameValues(sourcePlaceholders, localePlaceholders)) continue
|
||||
|
||||
issues.push(
|
||||
`${locale}: key "${key}" placeholders differ ` +
|
||||
`(expected ${formatValues(sourcePlaceholders)}, found ${formatValues(localePlaceholders)})`,
|
||||
)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
const sourceCatalog = readCatalog(sourcePath)
|
||||
assertFlatStringCatalog('en', sourceCatalog)
|
||||
|
||||
const sourceKeys = Object.keys(sourceCatalog).sort()
|
||||
ensureDirectory(localesDir)
|
||||
const localeFiles = directoryFiles(localesDir).filter((file) => file.endsWith('.json'))
|
||||
const issues = []
|
||||
|
||||
for (const file of localeFiles) {
|
||||
const locale = file.replace(/\.json$/, '')
|
||||
const filePath = path.join(localesDir, file)
|
||||
const catalog = readCatalog(filePath)
|
||||
|
||||
assertFlatStringCatalog(locale, catalog)
|
||||
|
||||
if (locale === 'en') continue
|
||||
|
||||
const keys = Object.keys(catalog).sort()
|
||||
const missing = missingKeys(sourceKeys, keys)
|
||||
const extra = extraKeys(sourceKeys, keys)
|
||||
|
||||
if (missing.length > 0) {
|
||||
issues.push(`${locale}: missing ${missing.length} key(s)`)
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
issues.push(`${locale}: extra ${extra.length} key(s)`)
|
||||
}
|
||||
|
||||
issues.push(...placeholderIssues(locale, sourceCatalog, catalog))
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.error('Locale validation failed:')
|
||||
for (const issue of issues) {
|
||||
console.error(`- ${issue}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Validated ${localeFiles.length} locale catalog(s) against ${sourceKeys.length} English keys.`)
|
||||
238
src-tauri/Cargo.lock
generated
238
src-tauri/Cargo.lock
generated
@@ -892,7 +892,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1079,7 +1079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1239,6 +1239,15 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
@@ -1477,8 +1486,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1488,9 +1499,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1848,6 +1861,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2069,6 +2083,26 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -2207,6 +2241,26 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
@@ -2312,6 +2366,12 @@ dependencies = [
|
||||
"value-bag",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -2386,6 +2446,18 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -2489,6 +2561,25 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"crossbeam-channel",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio 0.8.11",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -2817,9 +2908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
version = "0.10.78"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
@@ -2849,9 +2940,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
version = "0.9.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -3311,6 +3402,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
@@ -3357,6 +3503,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3377,6 +3533,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3395,6 +3561,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3532,6 +3707,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
@@ -3539,6 +3715,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3546,6 +3724,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
@@ -3553,6 +3732,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3683,6 +3863,12 @@ version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3702,7 +3888,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3737,6 +3923,7 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3758,7 +3945,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3769,9 +3956,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -4523,9 +4710,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -4922,7 +5109,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5042,7 +5229,7 @@ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"mio 1.1.1",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
@@ -5103,7 +5290,9 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"notify",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5727,6 +5916,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webkit2gtk"
|
||||
version = "2.0.2"
|
||||
@@ -5780,6 +5979,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -5838,7 +6046,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -20,6 +20,7 @@ serde_json = "1.0"
|
||||
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"] }
|
||||
tauri-plugin-log = "2"
|
||||
gray_matter = "0.2"
|
||||
@@ -37,6 +38,7 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-prevent-default = "4.0.4"
|
||||
sentry = "0.37"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tempfile = "3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"core:default",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-set-title",
|
||||
"core:webview:allow-create-webview-window",
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiAgentId {
|
||||
ClaudeCode,
|
||||
Codex,
|
||||
Opencode,
|
||||
Pi,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiAgentPermissionMode {
|
||||
#[default]
|
||||
Safe,
|
||||
PowerUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -20,6 +28,9 @@ pub struct AiAgentAvailability {
|
||||
pub struct AiAgentsStatus {
|
||||
pub claude_code: AiAgentAvailability,
|
||||
pub codex: AiAgentAvailability,
|
||||
pub opencode: AiAgentAvailability,
|
||||
pub pi: AiAgentAvailability,
|
||||
pub gemini: AiAgentAvailability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -57,12 +68,22 @@ pub struct AiAgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: Option<AiAgentPermissionMode>,
|
||||
}
|
||||
|
||||
impl AiAgentStreamRequest {
|
||||
fn permission_mode(&self) -> AiAgentPermissionMode {
|
||||
self.permission_mode.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
AiAgentsStatus {
|
||||
claude_code: availability_from_claude(),
|
||||
codex: availability_from_codex(),
|
||||
codex: crate::codex_cli::check_cli(),
|
||||
opencode: crate::opencode_cli::check_cli(),
|
||||
pi: crate::pi_cli::check_cli(),
|
||||
gemini: crate::gemini_cli::check_cli(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,12 +91,14 @@ pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Res
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let permission_mode = request.permission_mode();
|
||||
match request.agent {
|
||||
AiAgentId::ClaudeCode => {
|
||||
let mapped = crate::claude_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::claude_cli::run_agent_stream(mapped, |event| {
|
||||
if let Some(mapped_event) = map_claude_event(event) {
|
||||
@@ -83,7 +106,42 @@ where
|
||||
}
|
||||
})
|
||||
}
|
||||
AiAgentId::Codex => run_codex_agent_stream(request, emit),
|
||||
AiAgentId::Codex => {
|
||||
let mapped = crate::codex_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::codex_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Opencode => {
|
||||
let mapped = crate::opencode_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::opencode_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Pi => {
|
||||
let mapped = crate::pi_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::pi_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
AiAgentId::Gemini => {
|
||||
let mapped = crate::gemini_cli::AgentStreamRequest {
|
||||
message: request.message,
|
||||
system_prompt: request.system_prompt,
|
||||
vault_path: request.vault_path,
|
||||
permission_mode,
|
||||
};
|
||||
crate::gemini_cli::run_agent_stream(mapped, emit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,262 +153,6 @@ fn availability_from_claude() -> AiAgentAvailability {
|
||||
}
|
||||
}
|
||||
|
||||
fn availability_from_codex() -> AiAgentAvailability {
|
||||
let binary = match find_codex_binary() {
|
||||
Ok(binary) => binary,
|
||||
Err(_) => {
|
||||
return AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
AiAgentAvailability {
|
||||
installed: true,
|
||||
version: version_for_binary(&binary),
|
||||
}
|
||||
}
|
||||
|
||||
fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
Command::new(binary)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn find_codex_binary() -> Result<PathBuf, String> {
|
||||
if let Some(binary) = find_codex_binary_on_path()? {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
|
||||
return Ok(binary);
|
||||
}
|
||||
|
||||
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
|
||||
}
|
||||
|
||||
fn find_codex_binary_on_path() -> Result<Option<PathBuf>, String> {
|
||||
let output = Command::new("which")
|
||||
.arg("codex")
|
||||
.output()
|
||||
.map_err(|error| format!("Failed to run `which codex`: {error}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(Some(PathBuf::from(path)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn codex_binary_candidates() -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
vec![
|
||||
home.join(".local/bin/codex"),
|
||||
home.join(".npm/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
]
|
||||
}
|
||||
|
||||
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
|
||||
candidates.into_iter().find(|candidate| candidate.exists())
|
||||
}
|
||||
|
||||
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let binary = find_codex_binary()?;
|
||||
let args = build_codex_args(&request)?;
|
||||
let prompt = build_codex_prompt(&request);
|
||||
|
||||
let mut command = Command::new(binary);
|
||||
command
|
||||
.args(args)
|
||||
.arg(prompt)
|
||||
.current_dir(&request.vault_path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("Failed to spawn codex: {error}"))?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
|
||||
let mut thread_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(line) => line,
|
||||
Err(error) => {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format!("Read error: {error}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let json = match serde_json::from_str::<serde_json::Value>(&line) {
|
||||
Ok(json) => json,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(id) = json["thread_id"].as_str() {
|
||||
thread_id = id.to_string();
|
||||
}
|
||||
|
||||
dispatch_codex_event(&json, &mut emit);
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
if !status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_codex_error(stderr_output, status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, String> {
|
||||
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
|
||||
let mcp_server_path = mcp_server
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string();
|
||||
|
||||
Ok(vec![
|
||||
"exec".into(),
|
||||
"--json".into(),
|
||||
"--dangerously-bypass-approvals-and-sandbox".into(),
|
||||
"-C".into(),
|
||||
request.vault_path.clone(),
|
||||
"-c".into(),
|
||||
r#"mcp_servers.tolaria.command="node""#.into(),
|
||||
"-c".into(),
|
||||
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
|
||||
"-c".into(),
|
||||
format!(
|
||||
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
|
||||
request.vault_path
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn build_codex_prompt(request: &AiAgentStreamRequest) -> String {
|
||||
match request
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|prompt| prompt.trim())
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => format!(
|
||||
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
|
||||
request.message
|
||||
),
|
||||
None => request.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match json["type"].as_str().unwrap_or_default() {
|
||||
"thread.started" => {
|
||||
if let Some(thread_id) = json["thread_id"].as_str() {
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: thread_id.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
"item.started" => emit_codex_item_event(json, false, emit),
|
||||
"item.completed" => emit_codex_item_event(json, true, emit),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let item = &json["item"];
|
||||
let item_type = item["type"].as_str().unwrap_or_default();
|
||||
let item_id = item["id"].as_str().unwrap_or_default();
|
||||
|
||||
match item_type {
|
||||
"command_execution" => {
|
||||
if completed {
|
||||
emit(AiAgentStreamEvent::ToolDone {
|
||||
tool_id: item_id.to_string(),
|
||||
output: item["aggregated_output"]
|
||||
.as_str()
|
||||
.map(|output| output.to_string()),
|
||||
});
|
||||
} else {
|
||||
emit(AiAgentStreamEvent::ToolStart {
|
||||
tool_name: "Bash".into(),
|
||||
tool_id: item_id.to_string(),
|
||||
input: item["command"]
|
||||
.as_str()
|
||||
.map(|command| serde_json::json!({ "command": command }).to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
"agent_message" if completed => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
emit(AiAgentStreamEvent::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_codex_error(stderr_output: String, status: String) -> String {
|
||||
let lower = stderr_output.to_ascii_lowercase();
|
||||
if is_codex_auth_error(&lower) {
|
||||
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
|
||||
}
|
||||
|
||||
if stderr_output.trim().is_empty() {
|
||||
format!("codex exited with status {status}")
|
||||
} else {
|
||||
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_codex_auth_error(lower: &str) -> bool {
|
||||
["auth", "login", "sign in"]
|
||||
.iter()
|
||||
.any(|pattern| lower.contains(pattern))
|
||||
}
|
||||
|
||||
fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAgentStreamEvent> {
|
||||
match event {
|
||||
crate::claude_cli::ClaudeStreamEvent::Init { session_id } => {
|
||||
@@ -378,6 +180,9 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
|
||||
Some(AiAgentStreamEvent::Error { message })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done),
|
||||
crate::claude_cli::ClaudeStreamEvent::Result { text, .. } if !text.is_empty() => {
|
||||
Some(AiAgentStreamEvent::TextDelta { text })
|
||||
}
|
||||
crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -386,79 +191,44 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_status_contains_both_agents() {
|
||||
let status = get_ai_agents_status();
|
||||
assert!(matches!(status.claude_code.installed, true | false));
|
||||
assert!(matches!(status.codex.installed, true | false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_codex_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_codex_prompt(&AiAgentStreamRequest {
|
||||
fn request_with_permission(
|
||||
permission_mode: Option<AiAgentPermissionMode>,
|
||||
) -> AiAgentStreamRequest {
|
||||
AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "Rename the note".into(),
|
||||
system_prompt: Some("Be concise".into()),
|
||||
message: "Summarize this vault".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/tmp/vault".into(),
|
||||
});
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
permission_mode,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_command_events_maps_to_bash_events() {
|
||||
let mut events = Vec::new();
|
||||
let started = serde_json::json!({
|
||||
"type": "item.started",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"command": "/bin/zsh -lc pwd"
|
||||
}
|
||||
});
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_1",
|
||||
"type": "command_execution",
|
||||
"aggregated_output": "/private/tmp\n"
|
||||
}
|
||||
});
|
||||
|
||||
dispatch_codex_event(&started, &mut |event| events.push(event));
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
|
||||
if tool_name == "Bash" && tool_id == "item_1"
|
||||
));
|
||||
assert!(matches!(
|
||||
&events[1],
|
||||
AiAgentStreamEvent::ToolDone { tool_id, output }
|
||||
if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
|
||||
));
|
||||
fn stream_request_uses_default_or_explicit_permission_mode() {
|
||||
assert_eq!(
|
||||
request_with_permission(None).permission_mode(),
|
||||
AiAgentPermissionMode::Safe
|
||||
);
|
||||
assert_eq!(
|
||||
request_with_permission(Some(AiAgentPermissionMode::PowerUser)).permission_mode(),
|
||||
AiAgentPermissionMode::PowerUser
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_codex_agent_message_maps_to_text_delta() {
|
||||
let mut events = Vec::new();
|
||||
let completed = serde_json::json!({
|
||||
"type": "item.completed",
|
||||
"item": {
|
||||
"id": "item_2",
|
||||
"type": "agent_message",
|
||||
"text": "All set"
|
||||
}
|
||||
});
|
||||
fn normalize_status_contains_all_agents() {
|
||||
let status = get_ai_agents_status();
|
||||
let install_flags = [
|
||||
status.claude_code.installed,
|
||||
status.codex.installed,
|
||||
status.opencode.installed,
|
||||
status.pi.installed,
|
||||
status.gemini.installed,
|
||||
];
|
||||
|
||||
dispatch_codex_event(&completed, &mut |event| events.push(event));
|
||||
|
||||
assert!(matches!(
|
||||
&events[0],
|
||||
AiAgentStreamEvent::TextDelta { text } if text == "All set"
|
||||
));
|
||||
assert!(install_flags
|
||||
.iter()
|
||||
.all(|installed| matches!(installed, true | false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -467,4 +237,87 @@ mod tests {
|
||||
|
||||
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_claude_text_events_preserve_stream_data() {
|
||||
assert!(matches!(
|
||||
map_claude_event(crate::claude_cli::ClaudeStreamEvent::Init {
|
||||
session_id: "session-1".into(),
|
||||
}),
|
||||
Some(AiAgentStreamEvent::Init { session_id }) if session_id == "session-1"
|
||||
));
|
||||
assert!(matches!(
|
||||
map_claude_event(crate::claude_cli::ClaudeStreamEvent::TextDelta {
|
||||
text: "visible output".into(),
|
||||
}),
|
||||
Some(AiAgentStreamEvent::TextDelta { text }) if text == "visible output"
|
||||
));
|
||||
assert!(matches!(
|
||||
map_claude_event(crate::claude_cli::ClaudeStreamEvent::ThinkingDelta {
|
||||
text: "thinking".into(),
|
||||
}),
|
||||
Some(AiAgentStreamEvent::ThinkingDelta { text }) if text == "thinking"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_claude_tool_events_preserve_stream_data() {
|
||||
let started = map_claude_event(crate::claude_cli::ClaudeStreamEvent::ToolStart {
|
||||
tool_name: "Read".into(),
|
||||
tool_id: "tool-1".into(),
|
||||
input: Some("{\"file\":\"note.md\"}".into()),
|
||||
});
|
||||
let finished = map_claude_event(crate::claude_cli::ClaudeStreamEvent::ToolDone {
|
||||
tool_id: "tool-1".into(),
|
||||
output: Some("done".into()),
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
started,
|
||||
Some(AiAgentStreamEvent::ToolStart { tool_name, tool_id, input })
|
||||
if tool_name == "Read"
|
||||
&& tool_id == "tool-1"
|
||||
&& input.as_deref() == Some("{\"file\":\"note.md\"}")
|
||||
));
|
||||
assert!(matches!(
|
||||
finished,
|
||||
Some(AiAgentStreamEvent::ToolDone { tool_id, output })
|
||||
if tool_id == "tool-1" && output.as_deref() == Some("done")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_claude_error_event_preserves_message() {
|
||||
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Error {
|
||||
message: "missing auth".into(),
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
mapped,
|
||||
Some(AiAgentStreamEvent::Error { message }) if message == "missing auth"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_claude_result_event_preserves_final_text() {
|
||||
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result {
|
||||
text: "Final answer from Claude".into(),
|
||||
session_id: "session-1".into(),
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
mapped,
|
||||
Some(AiAgentStreamEvent::TextDelta { text }) if text == "Final answer from Claude"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_claude_empty_result_event_is_ignored() {
|
||||
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result {
|
||||
text: String::new(),
|
||||
session_id: "session-1".into(),
|
||||
});
|
||||
|
||||
assert!(mapped.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
701
src-tauri/src/ai_models.rs
Normal file
701
src-tauri/src/ai_models.rs
Normal file
@@ -0,0 +1,701 @@
|
||||
use crate::ai_agents::AiAgentStreamEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiModelProviderKind {
|
||||
OpenAi,
|
||||
Anthropic,
|
||||
OpenAiCompatible,
|
||||
Ollama,
|
||||
LmStudio,
|
||||
OpenRouter,
|
||||
Gemini,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AiModelApiKeyStorage {
|
||||
None,
|
||||
Env,
|
||||
LocalFile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AiModelCapabilities {
|
||||
pub streaming: bool,
|
||||
pub tools: bool,
|
||||
pub vision: bool,
|
||||
pub json_mode: bool,
|
||||
pub reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AiModelDefinition {
|
||||
pub id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub context_window: Option<u32>,
|
||||
pub max_output_tokens: Option<u32>,
|
||||
pub capabilities: AiModelCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AiModelProvider {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: AiModelProviderKind,
|
||||
pub base_url: Option<String>,
|
||||
pub api_key_storage: Option<AiModelApiKeyStorage>,
|
||||
pub api_key_env_var: Option<String>,
|
||||
pub headers: Option<BTreeMap<String, String>>,
|
||||
pub models: Vec<AiModelDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AiModelStreamRequest {
|
||||
pub provider: AiModelProvider,
|
||||
pub model_id: String,
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub api_key_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AiModelProviderTestRequest {
|
||||
pub provider: AiModelProvider,
|
||||
pub model_id: String,
|
||||
pub api_key_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct AiModelProviderCatalogEntry {
|
||||
kind: AiModelProviderKind,
|
||||
runtime_base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
|
||||
struct AiProviderSecrets {
|
||||
provider_api_keys: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
static AI_MODEL_PROVIDER_CATALOG: OnceLock<Vec<AiModelProviderCatalogEntry>> = OnceLock::new();
|
||||
|
||||
fn provider_catalog() -> &'static [AiModelProviderCatalogEntry] {
|
||||
AI_MODEL_PROVIDER_CATALOG
|
||||
.get_or_init(|| {
|
||||
serde_json::from_str(include_str!("../../src/shared/aiModelProviderCatalog.json"))
|
||||
.expect("bundled AI model provider catalog must be valid JSON")
|
||||
})
|
||||
.as_slice()
|
||||
}
|
||||
|
||||
fn provider_default_base_url(kind: &AiModelProviderKind) -> Option<&'static str> {
|
||||
provider_catalog()
|
||||
.iter()
|
||||
.find(|entry| entry.kind == *kind)
|
||||
.and_then(|entry| entry.runtime_base_url.as_deref())
|
||||
}
|
||||
|
||||
pub fn normalize_ai_model_providers(
|
||||
providers: Option<Vec<AiModelProvider>>,
|
||||
) -> Option<Vec<AiModelProvider>> {
|
||||
let normalized = providers?
|
||||
.into_iter()
|
||||
.filter_map(normalize_ai_model_provider)
|
||||
.collect::<Vec<_>>();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ai_model_provider(mut provider: AiModelProvider) -> Option<AiModelProvider> {
|
||||
provider.id = provider.id.trim().to_ascii_lowercase();
|
||||
provider.name = provider.name.trim().to_string();
|
||||
provider.base_url = normalize_optional_string(provider.base_url);
|
||||
provider.api_key_env_var = normalize_optional_string(provider.api_key_env_var);
|
||||
provider.api_key_storage = normalize_api_key_storage(&provider);
|
||||
provider.models = normalized_models(provider.models);
|
||||
|
||||
is_valid_provider(&provider).then_some(provider)
|
||||
}
|
||||
|
||||
fn normalize_api_key_storage(provider: &AiModelProvider) -> Option<AiModelApiKeyStorage> {
|
||||
match provider.api_key_storage {
|
||||
Some(AiModelApiKeyStorage::LocalFile) => Some(AiModelApiKeyStorage::LocalFile),
|
||||
Some(AiModelApiKeyStorage::Env) | None if provider.api_key_env_var.is_some() => {
|
||||
Some(AiModelApiKeyStorage::Env)
|
||||
}
|
||||
_ => Some(AiModelApiKeyStorage::None),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_models(models: Vec<AiModelDefinition>) -> Vec<AiModelDefinition> {
|
||||
models
|
||||
.into_iter()
|
||||
.filter_map(|mut model| {
|
||||
model.id = model.id.trim().to_string();
|
||||
model.display_name = normalize_optional_string(model.display_name);
|
||||
(!model.id.is_empty()).then_some(model)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_valid_provider(provider: &AiModelProvider) -> bool {
|
||||
!provider.id.is_empty() && !provider.name.is_empty() && !provider.models.is_empty()
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|candidate| candidate.trim().to_string())
|
||||
.filter(|candidate| !candidate.is_empty())
|
||||
}
|
||||
|
||||
pub fn run_ai_model_stream<F>(request: AiModelStreamRequest, mut emit: F) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
emit(AiAgentStreamEvent::Init {
|
||||
session_id: format!("api-{}", uuid::Uuid::new_v4()),
|
||||
});
|
||||
|
||||
let text = send_model_message(&request)?;
|
||||
|
||||
emit(AiAgentStreamEvent::TextDelta { text });
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<String, String> {
|
||||
let request = AiModelStreamRequest {
|
||||
provider: request.provider,
|
||||
model_id: request.model_id,
|
||||
message: "Reply with exactly OK.".into(),
|
||||
system_prompt: Some(
|
||||
"You are testing whether this model endpoint is reachable. Reply with exactly OK."
|
||||
.into(),
|
||||
),
|
||||
api_key_override: normalize_optional_string(request.api_key_override),
|
||||
};
|
||||
send_model_message(&request)
|
||||
}
|
||||
|
||||
fn send_model_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
match request.provider.kind {
|
||||
AiModelProviderKind::Anthropic => send_anthropic_message(request),
|
||||
_ => send_openai_compatible_message(request),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_openai_compatible_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
let endpoint = format!("{}/chat/completions", normalized_base_url(request)?);
|
||||
let mut messages = Vec::new();
|
||||
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
|
||||
messages.push(serde_json::json!({ "role": "system", "content": system_prompt }));
|
||||
}
|
||||
messages.push(serde_json::json!({ "role": "user", "content": request.message }));
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": request.model_id,
|
||||
"messages": messages,
|
||||
"stream": false
|
||||
});
|
||||
let json = send_json_request(request, endpoint, payload)?;
|
||||
extract_openai_text(&json)
|
||||
}
|
||||
|
||||
fn send_anthropic_message(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
let endpoint = format!("{}/messages", normalized_base_url(request)?);
|
||||
let mut payload = serde_json::json!({
|
||||
"model": request.model_id,
|
||||
"max_tokens": selected_max_tokens(request),
|
||||
"messages": [{ "role": "user", "content": request.message }]
|
||||
});
|
||||
|
||||
if let Some(system_prompt) = non_empty_option(request.system_prompt.as_deref()) {
|
||||
payload["system"] = serde_json::Value::String(system_prompt.to_string());
|
||||
}
|
||||
|
||||
let json = send_json_request(request, endpoint, payload)?;
|
||||
extract_anthropic_text(&json)
|
||||
}
|
||||
|
||||
fn selected_max_tokens(request: &AiModelStreamRequest) -> u32 {
|
||||
request
|
||||
.provider
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.id == request.model_id)
|
||||
.and_then(|model| model.max_output_tokens)
|
||||
.unwrap_or(4096)
|
||||
}
|
||||
|
||||
fn normalized_base_url(request: &AiModelStreamRequest) -> Result<String, String> {
|
||||
let fallback = provider_default_base_url(&request.provider.kind).unwrap_or("");
|
||||
let base = request
|
||||
.provider
|
||||
.base_url
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
.unwrap_or(fallback)
|
||||
.trim_end_matches('/');
|
||||
if base.is_empty() {
|
||||
return Err("Custom API providers need a base URL.".into());
|
||||
}
|
||||
Ok(base.to_string())
|
||||
}
|
||||
|
||||
fn send_json_request(
|
||||
request: &AiModelStreamRequest,
|
||||
endpoint: String,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|error| format!("Failed to create HTTP client: {error}"))?;
|
||||
let builder = client.post(endpoint).json(&payload);
|
||||
let builder = apply_auth_headers(builder, request)?;
|
||||
let builder = apply_provider_headers(builder, request);
|
||||
let response = send_provider_request(builder)?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.map_err(|error| format!("Failed to read AI provider response: {error}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"AI provider returned {status}: {}",
|
||||
truncate_error(&text)
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|error| format!("Failed to parse AI provider response: {error}"))
|
||||
}
|
||||
|
||||
fn apply_auth_headers(
|
||||
builder: reqwest::blocking::RequestBuilder,
|
||||
request: &AiModelStreamRequest,
|
||||
) -> Result<reqwest::blocking::RequestBuilder, String> {
|
||||
let Some(api_key) = api_key_from_provider(request)? else {
|
||||
return Ok(builder);
|
||||
};
|
||||
|
||||
Ok(match request.provider.kind {
|
||||
AiModelProviderKind::Anthropic => builder.header("x-api-key", api_key),
|
||||
_ => builder.bearer_auth(api_key),
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_provider_headers(
|
||||
mut builder: reqwest::blocking::RequestBuilder,
|
||||
request: &AiModelStreamRequest,
|
||||
) -> reqwest::blocking::RequestBuilder {
|
||||
if matches!(request.provider.kind, AiModelProviderKind::Anthropic) {
|
||||
builder = builder.header("anthropic-version", "2023-06-01");
|
||||
}
|
||||
for (key, value) in safe_custom_headers(request) {
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
fn safe_custom_headers(request: &AiModelStreamRequest) -> Vec<(&String, &String)> {
|
||||
request
|
||||
.provider
|
||||
.headers
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|headers| headers.iter())
|
||||
.filter(|(key, value)| {
|
||||
!key.eq_ignore_ascii_case("authorization") && non_empty_option(Some(value)).is_some()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn send_provider_request(
|
||||
builder: reqwest::blocking::RequestBuilder,
|
||||
) -> Result<reqwest::blocking::Response, String> {
|
||||
builder
|
||||
.send()
|
||||
.map_err(|error| format!("AI provider request failed: {error}"))
|
||||
}
|
||||
|
||||
pub fn save_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
|
||||
let provider_id = normalize_secret_provider_id(&provider_id)?;
|
||||
let api_key = api_key.trim().to_string();
|
||||
if api_key.is_empty() {
|
||||
return Err("API key cannot be empty.".into());
|
||||
}
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.insert(provider_id, api_key);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
|
||||
pub fn delete_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
let provider_id = normalize_secret_provider_id(&provider_id)?;
|
||||
let path = secrets_path()?;
|
||||
let mut secrets = read_secrets_at(&path)?;
|
||||
secrets.provider_api_keys.remove(&provider_id);
|
||||
write_secrets_at(&path, &secrets)
|
||||
}
|
||||
|
||||
fn normalize_secret_provider_id(provider_id: &str) -> Result<String, String> {
|
||||
let provider_id = provider_id.trim().to_ascii_lowercase();
|
||||
if provider_id.is_empty() {
|
||||
Err("Provider ID cannot be empty.".into())
|
||||
} else {
|
||||
Ok(provider_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn secrets_path() -> Result<std::path::PathBuf, String> {
|
||||
crate::settings::preferred_app_config_path("ai-provider-secrets.json")
|
||||
}
|
||||
|
||||
fn read_secrets_at(path: &Path) -> Result<AiProviderSecrets, String> {
|
||||
if !path.exists() {
|
||||
return Ok(AiProviderSecrets::default());
|
||||
}
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|error| format!("Failed to read AI provider secrets: {error}"))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|error| format!("Failed to parse AI provider secrets: {error}"))
|
||||
}
|
||||
|
||||
fn write_secrets_at(path: &Path, secrets: &AiProviderSecrets) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("Failed to create AI provider secrets directory: {error}"))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(secrets)
|
||||
.map_err(|error| format!("Failed to serialize AI provider secrets: {error}"))?;
|
||||
write_secret_file(path, json)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|error| format!("Failed to open AI provider secrets file: {error}"))?;
|
||||
file.write_all(content.as_bytes())
|
||||
.map_err(|error| format!("Failed to write AI provider secrets: {error}"))?;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("Failed to secure AI provider secrets file: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_secret_file(path: &Path, content: String) -> Result<(), String> {
|
||||
fs::write(path, content)
|
||||
.map_err(|error| format!("Failed to write AI provider secrets: {error}"))
|
||||
}
|
||||
|
||||
fn api_key_from_local_file(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
let secrets = read_secrets_at(&secrets_path()?)?;
|
||||
let api_key = secrets
|
||||
.provider_api_keys
|
||||
.get(&request.provider.id)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
if api_key.is_none() {
|
||||
return Err(format!(
|
||||
"No local API key is saved for {}.",
|
||||
request.provider.name
|
||||
));
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
fn api_key_from_env(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
let Some(name) = request
|
||||
.provider
|
||||
.api_key_env_var
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
std::env::var(name)
|
||||
.map(Some)
|
||||
.map_err(|_| format!("Environment variable {name} is not set for this AI provider."))
|
||||
}
|
||||
|
||||
fn api_key_from_provider(request: &AiModelStreamRequest) -> Result<Option<String>, String> {
|
||||
if let Some(api_key) = request
|
||||
.api_key_override
|
||||
.as_deref()
|
||||
.and_then(non_empty_str)
|
||||
.map(str::to_string)
|
||||
{
|
||||
return Ok(Some(api_key));
|
||||
}
|
||||
|
||||
match request.provider.api_key_storage {
|
||||
Some(AiModelApiKeyStorage::LocalFile) => api_key_from_local_file(request),
|
||||
Some(AiModelApiKeyStorage::Env) => api_key_from_env(request),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_openai_text(json: &serde_json::Value) -> Result<String, String> {
|
||||
json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.ok_or_else(|| "AI provider response did not include assistant text.".to_string())
|
||||
}
|
||||
|
||||
fn extract_anthropic_text(json: &serde_json::Value) -> Result<String, String> {
|
||||
let text = json["content"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|block| block["text"].as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if text.trim().is_empty() {
|
||||
Err("Anthropic response did not include assistant text.".into())
|
||||
} else {
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_option(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn non_empty_str(value: &str) -> Option<&str> {
|
||||
non_empty_option(Some(value))
|
||||
}
|
||||
|
||||
fn truncate_error(value: &str) -> String {
|
||||
const MAX_ERROR_LENGTH: usize = 600;
|
||||
if value.len() <= MAX_ERROR_LENGTH {
|
||||
return value.to_string();
|
||||
}
|
||||
format!("{}...", &value[..MAX_ERROR_LENGTH])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn capabilities() -> AiModelCapabilities {
|
||||
AiModelCapabilities {
|
||||
streaming: true,
|
||||
tools: false,
|
||||
vision: false,
|
||||
json_mode: true,
|
||||
reasoning: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn model(id: &str) -> AiModelDefinition {
|
||||
AiModelDefinition {
|
||||
id: id.into(),
|
||||
display_name: Some(" Demo Model ".into()),
|
||||
context_window: Some(128_000),
|
||||
max_output_tokens: Some(8192),
|
||||
capabilities: capabilities(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(kind: AiModelProviderKind) -> AiModelProvider {
|
||||
AiModelProvider {
|
||||
id: " Demo ".into(),
|
||||
name: " Demo Provider ".into(),
|
||||
kind,
|
||||
base_url: Some(" https://example.com/v1/ ".into()),
|
||||
api_key_storage: None,
|
||||
api_key_env_var: Some(" DEMO_API_KEY ".into()),
|
||||
headers: None,
|
||||
models: vec![model(" demo-model "), model(" ")],
|
||||
}
|
||||
}
|
||||
|
||||
fn request(provider: AiModelProvider) -> AiModelStreamRequest {
|
||||
AiModelStreamRequest {
|
||||
provider,
|
||||
model_id: "demo-model".into(),
|
||||
message: "Hello".into(),
|
||||
system_prompt: Some(" Be concise. ".into()),
|
||||
api_key_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_providers_trims_values_and_filters_invalid_entries() {
|
||||
let invalid = AiModelProvider {
|
||||
id: " ".into(),
|
||||
name: "Missing".into(),
|
||||
kind: AiModelProviderKind::OpenAiCompatible,
|
||||
base_url: None,
|
||||
api_key_storage: None,
|
||||
api_key_env_var: None,
|
||||
headers: None,
|
||||
models: vec![model("ignored")],
|
||||
};
|
||||
|
||||
let providers = normalize_ai_model_providers(Some(vec![
|
||||
invalid,
|
||||
provider(AiModelProviderKind::OpenAiCompatible),
|
||||
]))
|
||||
.expect("valid provider should remain");
|
||||
|
||||
assert_eq!(providers.len(), 1);
|
||||
let normalized = &providers[0];
|
||||
assert_eq!(normalized.id, "demo");
|
||||
assert_eq!(normalized.name, "Demo Provider");
|
||||
assert_eq!(
|
||||
normalized.base_url.as_deref(),
|
||||
Some("https://example.com/v1/")
|
||||
);
|
||||
assert_eq!(normalized.api_key_env_var.as_deref(), Some("DEMO_API_KEY"));
|
||||
assert_eq!(normalized.api_key_storage, Some(AiModelApiKeyStorage::Env));
|
||||
assert_eq!(normalized.models.len(), 1);
|
||||
assert_eq!(normalized.models[0].id, "demo-model");
|
||||
assert_eq!(
|
||||
normalized.models[0].display_name.as_deref(),
|
||||
Some("Demo Model")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_providers_returns_none_for_missing_or_empty_sets() {
|
||||
assert_eq!(normalize_ai_model_providers(None), None);
|
||||
assert_eq!(normalize_ai_model_providers(Some(Vec::new())), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_request_helpers_resolve_defaults_and_safe_headers() {
|
||||
let mut provider = provider(AiModelProviderKind::Anthropic);
|
||||
provider.base_url = None;
|
||||
provider.headers = Some(BTreeMap::from([
|
||||
("Authorization".into(), "ignored".into()),
|
||||
("X-Demo".into(), "demo".into()),
|
||||
("X-Blank".into(), " ".into()),
|
||||
]));
|
||||
provider.models = vec![model("demo-model")];
|
||||
let request = request(provider);
|
||||
let headers = safe_custom_headers(&request)
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
normalized_base_url(&request).unwrap(),
|
||||
"https://api.anthropic.com/v1"
|
||||
);
|
||||
assert_eq!(selected_max_tokens(&request), 8192);
|
||||
assert_eq!(headers, vec![("X-Demo", "demo")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_provider_catalog_supplies_runtime_base_urls() {
|
||||
assert_eq!(
|
||||
provider_default_base_url(&AiModelProviderKind::OpenAi),
|
||||
Some("https://api.openai.com/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_default_base_url(&AiModelProviderKind::Ollama),
|
||||
Some("http://localhost:11434/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_default_base_url(&AiModelProviderKind::OpenAiCompatible),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_provider_requires_base_url() {
|
||||
let mut provider = provider(AiModelProviderKind::OpenAiCompatible);
|
||||
provider.base_url = Some(" ".into());
|
||||
|
||||
assert_eq!(
|
||||
normalized_base_url(&request(provider)).unwrap_err(),
|
||||
"Custom API providers need a base URL.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_provider_text_payloads_and_reports_empty_responses() {
|
||||
let openai = json!({
|
||||
"choices": [{ "message": { "content": "Hello from OpenAI" } }]
|
||||
});
|
||||
let anthropic = json!({
|
||||
"content": [
|
||||
{ "text": "Hello " },
|
||||
{ "text": "from Anthropic" }
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(extract_openai_text(&openai).unwrap(), "Hello from OpenAI");
|
||||
assert_eq!(
|
||||
extract_anthropic_text(&anthropic).unwrap(),
|
||||
"Hello from Anthropic"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_openai_text(&json!({ "choices": [] })).unwrap_err(),
|
||||
"AI provider response did not include assistant text.",
|
||||
);
|
||||
assert_eq!(
|
||||
extract_anthropic_text(&json!({ "content": [{ "type": "thinking" }] })).unwrap_err(),
|
||||
"Anthropic response did not include assistant text.",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saves_reads_and_validates_local_provider_secrets() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("nested/secrets.json");
|
||||
let secrets = AiProviderSecrets {
|
||||
provider_api_keys: BTreeMap::from([("demo".into(), "secret".into())]),
|
||||
};
|
||||
|
||||
write_secrets_at(&path, &secrets).unwrap();
|
||||
|
||||
assert_eq!(read_secrets_at(&path).unwrap(), secrets);
|
||||
assert_eq!(
|
||||
read_secrets_at(&dir.path().join("missing.json")).unwrap(),
|
||||
AiProviderSecrets::default()
|
||||
);
|
||||
assert_eq!(normalize_secret_provider_id(" Demo ").unwrap(), "demo");
|
||||
assert_eq!(
|
||||
normalize_secret_provider_id(" ").unwrap_err(),
|
||||
"Provider ID cannot be empty.",
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_provider_errors_without_touching_short_errors() {
|
||||
let short = "provider unavailable";
|
||||
let long = "x".repeat(605);
|
||||
|
||||
assert_eq!(truncate_error(short), short);
|
||||
assert_eq!(truncate_error(&long).len(), 603);
|
||||
assert!(truncate_error(&long).ends_with("..."));
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,8 @@ pub async fn download_and_install_app_update<R: Runtime>(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ReleaseChannel;
|
||||
use super::{AppUpdateDownloadEvent, AppUpdateMetadata, ReleaseChannel};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn release_channel_defaults_to_stable() {
|
||||
@@ -176,4 +177,54 @@ mod tests {
|
||||
"https://refactoringhq.github.io/tolaria/stable/latest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_metadata_serializes_for_frontend_consumers() {
|
||||
let metadata = AppUpdateMetadata {
|
||||
current_version: "2026.4.1".into(),
|
||||
version: "2026.4.2".into(),
|
||||
date: Some("2026-04-30T12:00:00Z".into()),
|
||||
body: Some("Bug fixes".into()),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(metadata).unwrap(),
|
||||
json!({
|
||||
"currentVersion": "2026.4.1",
|
||||
"version": "2026.4.2",
|
||||
"date": "2026-04-30T12:00:00Z",
|
||||
"body": "Bug fixes"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_events_serialize_as_tagged_frontend_events() {
|
||||
let events = [
|
||||
(
|
||||
AppUpdateDownloadEvent::Started {
|
||||
content_length: Some(4096),
|
||||
},
|
||||
json!({
|
||||
"event": "Started",
|
||||
"data": { "contentLength": 4096 }
|
||||
}),
|
||||
),
|
||||
(
|
||||
AppUpdateDownloadEvent::Progress { chunk_length: 512 },
|
||||
json!({
|
||||
"event": "Progress",
|
||||
"data": { "chunkLength": 512 }
|
||||
}),
|
||||
),
|
||||
(
|
||||
AppUpdateDownloadEvent::Finished,
|
||||
json!({ "event": "Finished" }),
|
||||
),
|
||||
];
|
||||
|
||||
for (event, expected) in events {
|
||||
assert_eq!(serde_json::to_value(event).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
369
src-tauri/src/cli_agent_runtime.rs
Normal file
369
src-tauri/src/cli_agent_runtime.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent};
|
||||
use serde::Deserialize;
|
||||
use std::ffi::OsString;
|
||||
use std::io::BufRead;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitStatus};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
pub system_prompt: Option<String>,
|
||||
pub vault_path: String,
|
||||
pub permission_mode: AiAgentPermissionMode,
|
||||
}
|
||||
|
||||
pub(crate) struct JsonLineRun {
|
||||
pub session_id: String,
|
||||
pub stderr_output: String,
|
||||
pub status: ExitStatus,
|
||||
}
|
||||
|
||||
pub(crate) fn build_prompt(message: &str, system_prompt: Option<&str>) -> String {
|
||||
match system_prompt
|
||||
.map(str::trim)
|
||||
.filter(|prompt| !prompt.is_empty())
|
||||
{
|
||||
Some(system_prompt) => {
|
||||
format!("System instructions:\n{system_prompt}\n\nUser request:\n{message}")
|
||||
}
|
||||
None => message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_server_path_string() -> Result<String, String> {
|
||||
Ok(crate::mcp::mcp_server_dir()?
|
||||
.join("index.js")
|
||||
.to_str()
|
||||
.ok_or("Invalid MCP server path")?
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
|
||||
let mut command = crate::hidden_command(binary);
|
||||
configure_agent_command_environment(&mut command, binary);
|
||||
command
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn configure_agent_command_environment(command: &mut Command, binary: &Path) {
|
||||
if let Some(path) = expanded_agent_path(binary) {
|
||||
command.env("PATH", path);
|
||||
}
|
||||
}
|
||||
|
||||
fn expanded_agent_path(binary: &Path) -> Option<OsString> {
|
||||
let mut paths = std::env::var_os("PATH")
|
||||
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
for candidate in agent_path_candidates(binary) {
|
||||
push_unique_path(&mut paths, candidate);
|
||||
}
|
||||
|
||||
std::env::join_paths(paths).ok()
|
||||
}
|
||||
|
||||
fn agent_path_candidates(binary: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Some(parent) = binary
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
candidates.push(parent.to_path_buf());
|
||||
}
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.extend([
|
||||
home.join(".local/bin"),
|
||||
home.join(".local/share/mise/shims"),
|
||||
home.join(".asdf/shims"),
|
||||
home.join(".npm-global/bin"),
|
||||
home.join(".npm/bin"),
|
||||
home.join(".bun/bin"),
|
||||
home.join(".linuxbrew/bin"),
|
||||
home.join("AppData/Roaming/npm"),
|
||||
home.join("AppData/Local/pnpm"),
|
||||
home.join("scoop/shims"),
|
||||
]);
|
||||
}
|
||||
|
||||
candidates.extend([
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
|
||||
]);
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn push_unique_path(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
|
||||
if candidate.as_os_str().is_empty() {
|
||||
return;
|
||||
}
|
||||
if paths.iter().any(|path| path == &candidate) {
|
||||
return;
|
||||
}
|
||||
paths.push(candidate);
|
||||
}
|
||||
|
||||
pub(crate) fn find_executable_binary_candidate(
|
||||
candidates: Vec<PathBuf>,
|
||||
agent_label: &str,
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
let mut first_unusable_candidate = None;
|
||||
|
||||
for candidate in candidates {
|
||||
if !candidate.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_executable_file(&candidate) {
|
||||
return Ok(Some(candidate));
|
||||
}
|
||||
|
||||
if first_unusable_candidate.is_none() {
|
||||
first_unusable_candidate = Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
match first_unusable_candidate {
|
||||
Some(candidate) => Err(format!(
|
||||
"{agent_label} binary found at {} but it is not executable. Fix the file permissions or reinstall the CLI.",
|
||||
candidate.display()
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_executable_file(path: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
std::fs::metadata(path)
|
||||
.map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
path.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_json_line(
|
||||
line: Result<String, std::io::Error>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
let line = line.map_err(|error| format!("Read error: {error}"))?;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(serde_json::from_str::<serde_json::Value>(trimmed).ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_ai_agent_json_line<F>(
|
||||
line: Result<String, std::io::Error>,
|
||||
emit: &mut F,
|
||||
) -> Option<serde_json::Value>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
match parse_json_line(line) {
|
||||
Ok(json) => json,
|
||||
Err(message) => {
|
||||
emit(AiAgentStreamEvent::Error { message });
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_json_line_process<Event, F, H>(
|
||||
mut command: Command,
|
||||
process_name: &'static str,
|
||||
emit: &mut F,
|
||||
error_event: impl Fn(String) -> Event,
|
||||
mut handle_json: H,
|
||||
) -> Result<JsonLineRun, String>
|
||||
where
|
||||
F: FnMut(Event),
|
||||
H: FnMut(&serde_json::Value, &mut F, &mut String),
|
||||
{
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format_spawn_error(process_name, &error))?;
|
||||
let stdout = child.stdout.take().ok_or("No stdout handle")?;
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let mut session_id = String::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
match parse_json_line(line) {
|
||||
Ok(Some(json)) => handle_json(&json, emit, &mut session_id),
|
||||
Ok(None) => {}
|
||||
Err(message) => {
|
||||
emit(error_event(message));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stderr_output = child
|
||||
.stderr
|
||||
.take()
|
||||
.and_then(|stderr| std::io::read_to_string(stderr).ok())
|
||||
.unwrap_or_default();
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|error| format!("Wait failed: {error}"))?;
|
||||
|
||||
Ok(JsonLineRun {
|
||||
session_id,
|
||||
stderr_output,
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
fn format_spawn_error(process_name: &str, error: &std::io::Error) -> String {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
return format!(
|
||||
"Failed to start {process_name}: the CLI or one of its runtime dependencies was not found. If it was installed with Homebrew, make sure /opt/homebrew/bin or /usr/local/bin contains the CLI and Node.js, then restart Tolaria. Details: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
format!("Failed to spawn {process_name}: {error}")
|
||||
}
|
||||
|
||||
pub(crate) fn run_ai_agent_json_stream<F>(
|
||||
command: Command,
|
||||
process_name: &'static str,
|
||||
mut emit: F,
|
||||
session_id: impl Fn(&serde_json::Value) -> Option<&str>,
|
||||
dispatch_event: impl Fn(&serde_json::Value, &mut F),
|
||||
format_error: impl Fn(String, String) -> String,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
F: FnMut(AiAgentStreamEvent),
|
||||
{
|
||||
let run = run_json_line_process(
|
||||
command,
|
||||
process_name,
|
||||
&mut emit,
|
||||
|message| AiAgentStreamEvent::Error { message },
|
||||
|json, emit, active_session_id| {
|
||||
if let Some(id) = session_id(json) {
|
||||
*active_session_id = id.to_string();
|
||||
}
|
||||
dispatch_event(json, emit);
|
||||
},
|
||||
)?;
|
||||
|
||||
if !run.status.success() {
|
||||
emit(AiAgentStreamEvent::Error {
|
||||
message: format_error(run.stderr_output, run.status.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
emit(AiAgentStreamEvent::Done);
|
||||
Ok(run.session_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_prompt_keeps_system_prompt_first() {
|
||||
let prompt = build_prompt("Rename the note", Some("Be concise"));
|
||||
|
||||
assert!(prompt.starts_with("System instructions:\nBe concise"));
|
||||
assert!(prompt.contains("User request:\nRename the note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompt_skips_blank_system_prompt() {
|
||||
assert_eq!(
|
||||
build_prompt("Rename the note", Some(" ")),
|
||||
"Rename the note"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines() {
|
||||
assert!(parse_json_line(Ok(" ".into())).unwrap().is_none());
|
||||
assert!(parse_json_line(Ok("not json".into())).unwrap().is_none());
|
||||
|
||||
let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err();
|
||||
assert!(error.contains("broken pipe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_command_environment_keeps_homebrew_shims_available() {
|
||||
let mut command = Command::new("/opt/homebrew/bin/codex");
|
||||
configure_agent_command_environment(&mut command, Path::new("/opt/homebrew/bin/codex"));
|
||||
let path = command
|
||||
.get_envs()
|
||||
.find(|(key, _)| *key == std::ffi::OsStr::new("PATH"))
|
||||
.and_then(|(_, value)| value)
|
||||
.expect("PATH should be set");
|
||||
let paths = std::env::split_paths(path).collect::<Vec<_>>();
|
||||
|
||||
assert!(paths.contains(&PathBuf::from("/opt/homebrew/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/usr/local/bin")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_not_found_errors_explain_gui_path_runtime_dependencies() {
|
||||
let error = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory");
|
||||
let message = format_spawn_error("codex", &error);
|
||||
|
||||
assert!(message.contains("Failed to start codex"));
|
||||
assert!(message.contains("/opt/homebrew/bin"));
|
||||
assert!(message.contains("Node.js"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn executable_binary_candidate_skips_unusable_file_when_later_candidate_works() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let unusable = dir.path().join("codex-unusable");
|
||||
let executable = dir.path().join("codex");
|
||||
std::fs::write(&unusable, "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(&executable, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let found =
|
||||
find_executable_binary_candidate(vec![unusable, executable.clone()], "Codex CLI")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(found, Some(executable));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn executable_binary_candidate_reports_unusable_file_when_no_candidate_works() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let unusable = dir.path().join("opencode");
|
||||
std::fs::write(&unusable, "#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&unusable, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
|
||||
let error =
|
||||
find_executable_binary_candidate(vec![unusable.clone()], "OpenCode CLI").unwrap_err();
|
||||
|
||||
assert!(error.contains("OpenCode CLI binary found"));
|
||||
assert!(error.contains(&unusable.display().to_string()));
|
||||
assert!(error.contains("not executable"));
|
||||
}
|
||||
}
|
||||
1171
src-tauri/src/codex_cli.rs
Normal file
1171
src-tauri/src/codex_cli.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
#[cfg(desktop)]
|
||||
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
|
||||
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
|
||||
#[cfg(desktop)]
|
||||
use crate::ai_models::{AiModelProviderTestRequest, AiModelStreamRequest};
|
||||
use crate::claude_cli::{ChatStreamRequest, ClaudeCliStatus};
|
||||
use crate::vault::VaultAiGuidanceStatus;
|
||||
|
||||
use super::expand_tilde;
|
||||
@@ -82,21 +84,53 @@ define_desktop_stream_command!(
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_claude_agent,
|
||||
AgentStreamRequest,
|
||||
"claude-agent-stream",
|
||||
crate::claude_cli::run_agent_stream
|
||||
);
|
||||
fn normalize_agent_request(mut request: AiAgentStreamRequest) -> AiAgentStreamRequest {
|
||||
request.vault_path = expand_tilde(&request.vault_path).into_owned();
|
||||
request
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn run_normalized_ai_agent_stream(
|
||||
request: AiAgentStreamRequest,
|
||||
emitter: StreamEmitter<crate::ai_agents::AiAgentStreamEvent>,
|
||||
) -> Result<String, String> {
|
||||
crate::ai_agents::run_ai_agent_stream(normalize_agent_request(request), emitter)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_agent,
|
||||
AiAgentStreamRequest,
|
||||
"ai-agent-stream",
|
||||
crate::ai_agents::run_ai_agent_stream
|
||||
run_normalized_ai_agent_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
define_desktop_stream_command!(
|
||||
stream_ai_model,
|
||||
AiModelStreamRequest,
|
||||
"ai-model-stream",
|
||||
crate::ai_models::run_ai_model_stream
|
||||
);
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn save_ai_model_provider_api_key(provider_id: String, api_key: String) -> Result<(), String> {
|
||||
crate::ai_models::save_provider_api_key(provider_id, api_key)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn delete_ai_model_provider_api_key(provider_id: String) -> Result<(), String> {
|
||||
crate::ai_models::delete_provider_api_key(provider_id)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn test_ai_model_provider(request: AiModelProviderTestRequest) -> Result<String, String> {
|
||||
crate::ai_models::test_ai_model_provider(request)
|
||||
}
|
||||
|
||||
// ── Claude CLI (mobile stubs) ───────────────────────────────────────────────
|
||||
|
||||
#[cfg(mobile)]
|
||||
@@ -120,6 +154,18 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
opencode: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
pi: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
gemini: crate::ai_agents::AiAgentAvailability {
|
||||
installed: false,
|
||||
version: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,15 +178,6 @@ pub async fn stream_claude_chat(
|
||||
Err("Claude CLI is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_claude_agent(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_request: AgentStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Claude CLI is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_agent(
|
||||
@@ -149,3 +186,104 @@ pub async fn stream_ai_agent(
|
||||
) -> Result<String, String> {
|
||||
Err("CLI AI agents are not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn stream_ai_model(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_request: crate::ai_models::AiModelStreamRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Direct AI model chat is not available in this mobile build yet.".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn save_ai_model_provider_api_key(
|
||||
_provider_id: String,
|
||||
_api_key: String,
|
||||
) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn delete_ai_model_provider_api_key(_provider_id: String) -> Result<(), String> {
|
||||
Err("Local AI provider secret storage is only available in the desktop app.".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn test_ai_model_provider(
|
||||
_request: crate::ai_models::AiModelProviderTestRequest,
|
||||
) -> Result<String, String> {
|
||||
Err("Direct AI model tests are not available in this mobile build yet.".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vault::AiGuidanceFileState;
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn normalize_agent_request_expands_tilde_in_vault_path() {
|
||||
use crate::ai_agents::AiAgentId;
|
||||
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let request = AiAgentStreamRequest {
|
||||
agent: AiAgentId::ClaudeCode,
|
||||
message: "hi".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "~/Vaults/content".into(),
|
||||
permission_mode: None,
|
||||
};
|
||||
|
||||
let normalized = normalize_agent_request(request);
|
||||
|
||||
assert_eq!(
|
||||
normalized.vault_path,
|
||||
format!("{}/Vaults/content", home.display()),
|
||||
"vault_path must be tilde-expanded so spawned agents can chdir into it",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[test]
|
||||
fn normalize_agent_request_leaves_absolute_vault_path_untouched() {
|
||||
use crate::ai_agents::AiAgentId;
|
||||
|
||||
let request = AiAgentStreamRequest {
|
||||
agent: AiAgentId::Codex,
|
||||
message: "hi".into(),
|
||||
system_prompt: None,
|
||||
vault_path: "/Users/example/vault".into(),
|
||||
permission_mode: None,
|
||||
};
|
||||
|
||||
let normalized = normalize_agent_request(request);
|
||||
|
||||
assert_eq!(normalized.vault_path, "/Users/example/vault");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guidance_commands_report_and_restore_vault_guidance_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_string_lossy().to_string();
|
||||
|
||||
let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap();
|
||||
assert_eq!(initial.agents_state, AiGuidanceFileState::Missing);
|
||||
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
|
||||
assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing);
|
||||
assert!(initial.can_restore);
|
||||
|
||||
let restored = restore_vault_ai_guidance(vault_path.clone()).unwrap();
|
||||
assert_eq!(restored.agents_state, AiGuidanceFileState::Managed);
|
||||
assert_eq!(restored.claude_state, AiGuidanceFileState::Managed);
|
||||
assert_eq!(restored.gemini_state, AiGuidanceFileState::Managed);
|
||||
assert!(!restored.can_restore);
|
||||
|
||||
assert!(dir.path().join("AGENTS.md").exists());
|
||||
assert!(dir.path().join("CLAUDE.md").exists());
|
||||
assert!(dir.path().join("GEMINI.md").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,32 @@ pub async fn batch_delete_notes_async(
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_delete_notes_async_validates_and_deletes_inside_vault() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let first = dir.path().join("first.md");
|
||||
let second = dir.path().join("nested/second.md");
|
||||
std::fs::create_dir_all(second.parent().unwrap()).unwrap();
|
||||
std::fs::write(&first, "# First\n").unwrap();
|
||||
std::fs::write(&second, "# Second\n").unwrap();
|
||||
|
||||
let deleted = batch_delete_notes_async(
|
||||
vec![
|
||||
first.to_string_lossy().to_string(),
|
||||
"nested/second.md".to_string(),
|
||||
],
|
||||
Some(dir.path().to_string_lossy().to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!first.exists());
|
||||
assert!(!second.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,3 +21,30 @@ pub fn delete_vault_folder(vault_path: String, folder_path: String) -> Result<St
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::delete_folder(std::path::Path::new(vault_path.as_ref()), &folder_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn folder_commands_route_through_vault_path_boundary() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().to_string_lossy().to_string();
|
||||
let folder = dir.path().join("Inbox");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
std::fs::write(folder.join("note.md"), "# Note\n").unwrap();
|
||||
|
||||
let renamed = rename_vault_folder(
|
||||
vault_path.clone(),
|
||||
"Inbox".to_string(),
|
||||
"Organized".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(renamed.new_path.ends_with("Organized"));
|
||||
assert!(dir.path().join("Organized/note.md").exists());
|
||||
|
||||
let deleted = delete_vault_folder(vault_path, "Organized".to_string()).unwrap();
|
||||
assert_eq!(deleted, "Organized");
|
||||
assert!(!dir.path().join("Organized").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,22 @@ use crate::git::{
|
||||
|
||||
use super::expand_tilde;
|
||||
|
||||
type VaultPathArg = String;
|
||||
type NotePathArg = String;
|
||||
type CommitHashArg = String;
|
||||
type CommitMessageArg = String;
|
||||
type ConflictStrategyArg = String;
|
||||
type RemoteUrlArg = String;
|
||||
type LocalPathArg = String;
|
||||
|
||||
// ── Git commands (desktop) ──────────────────────────────────────────────────
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
|
||||
pub fn get_file_history(
|
||||
vault_path: VaultPathArg,
|
||||
path: NotePathArg,
|
||||
) -> Result<Vec<GitCommit>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
crate::git::get_file_history(&vault_path, &path)
|
||||
@@ -17,14 +28,14 @@ pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommi
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
pub fn get_modified_files(vault_path: VaultPathArg) -> Result<Vec<ModifiedFile>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_modified_files(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
|
||||
pub fn get_file_diff(vault_path: VaultPathArg, path: NotePathArg) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
crate::git::get_file_diff(&vault_path, &path)
|
||||
@@ -33,9 +44,9 @@ pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String>
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff_at_commit(
|
||||
vault_path: String,
|
||||
path: String,
|
||||
commit_hash: String,
|
||||
vault_path: VaultPathArg,
|
||||
path: NotePathArg,
|
||||
commit_hash: CommitHashArg,
|
||||
) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
let path = expand_tilde(&path);
|
||||
@@ -45,7 +56,7 @@ pub fn get_file_diff_at_commit(
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: String,
|
||||
vault_path: VaultPathArg,
|
||||
limit: Option<usize>,
|
||||
skip: Option<usize>,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
@@ -57,21 +68,21 @@ pub fn get_vault_pulse(
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
|
||||
pub fn git_commit(vault_path: VaultPathArg, message: CommitMessageArg) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::git_commit(&vault_path, &message)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
pub fn get_last_commit_info(vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_last_commit_info(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
pub async fn git_pull(vault_path: VaultPathArg) -> Result<GitPullResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_pull(&vault_path))
|
||||
.await
|
||||
@@ -80,14 +91,14 @@ pub async fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
|
||||
pub fn get_conflict_files(vault_path: VaultPathArg) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_conflict_files(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_mode(vault_path: String) -> String {
|
||||
pub fn get_conflict_mode(vault_path: VaultPathArg) -> String {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::get_conflict_mode(&vault_path)
|
||||
}
|
||||
@@ -95,9 +106,9 @@ pub fn get_conflict_mode(vault_path: String) -> String {
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_resolve_conflict(
|
||||
vault_path: String,
|
||||
file: String,
|
||||
strategy: String,
|
||||
vault_path: VaultPathArg,
|
||||
file: NotePathArg,
|
||||
strategy: ConflictStrategyArg,
|
||||
) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::git_resolve_conflict(&vault_path, &file, &strategy)
|
||||
@@ -105,14 +116,14 @@ pub fn git_resolve_conflict(
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
|
||||
pub fn git_commit_conflict_resolution(vault_path: VaultPathArg) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::git_commit_conflict_resolution(&vault_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
pub async fn git_push(vault_path: VaultPathArg) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_push(&vault_path))
|
||||
.await
|
||||
@@ -121,7 +132,7 @@ pub async fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
pub async fn git_remote_status(vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
|
||||
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||
tokio::task::spawn_blocking(move || crate::git::git_remote_status(&vault_path))
|
||||
.await
|
||||
@@ -130,14 +141,17 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn git_discard_file(vault_path: String, relative_path: String) -> Result<(), String> {
|
||||
pub fn git_discard_file(
|
||||
vault_path: VaultPathArg,
|
||||
relative_path: NotePathArg,
|
||||
) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::discard_file_changes(&vault_path, &relative_path)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(vault_path: String) -> bool {
|
||||
pub fn is_git_repo(vault_path: VaultPathArg) -> bool {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
std::path::Path::new(vault_path.as_ref())
|
||||
.join(".git")
|
||||
@@ -145,15 +159,67 @@ pub fn is_git_repo(vault_path: String) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(vault_path: String) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
crate::git::init_repo(&vault_path)
|
||||
fn validate_git_init_target(vault_path: &str) -> Result<(), String> {
|
||||
let path = std::path::Path::new(vault_path);
|
||||
if !path.exists() {
|
||||
return Err("Choose an existing vault folder before initializing Git".to_string());
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err("Choose a folder before initializing Git".to_string());
|
||||
}
|
||||
|
||||
if is_broad_personal_folder(path) && !has_tolaria_vault_marker(path) {
|
||||
return Err(format!(
|
||||
"Choose a dedicated vault folder before initializing Git. '{}' looks like a broad personal folder; create or select a subfolder such as '{}' instead.",
|
||||
path.display(),
|
||||
path.join("Tolaria").display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn is_broad_personal_folder(path: &std::path::Path) -> bool {
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
matches!(
|
||||
name.to_ascii_lowercase().as_str(),
|
||||
"desktop"
|
||||
| "documents"
|
||||
| "downloads"
|
||||
| "movies"
|
||||
| "music"
|
||||
| "pictures"
|
||||
| "public"
|
||||
| "templates"
|
||||
| "videos"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn has_tolaria_vault_marker(path: &std::path::Path) -> bool {
|
||||
["AGENTS.md", "CLAUDE.md", "type.md", "note.md"]
|
||||
.iter()
|
||||
.any(|file| path.join(file).is_file())
|
||||
|| ["attachments", "type", "views"]
|
||||
.iter()
|
||||
.any(|dir| path.join(dir).is_dir())
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
validate_git_init_target(&vault_path)?;
|
||||
crate::git::init_repo(std::path::Path::new(vault_path.as_ref()))
|
||||
}
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(url: RemoteUrlArg, local_path: LocalPathArg) -> Result<String, String> {
|
||||
let local_path = expand_tilde(&local_path);
|
||||
crate::git::clone_repo(&url, &local_path)
|
||||
}
|
||||
@@ -162,28 +228,31 @@ pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_history(_vault_path: String, _path: String) -> Result<Vec<GitCommit>, String> {
|
||||
pub fn get_file_history(
|
||||
_vault_path: VaultPathArg,
|
||||
_path: NotePathArg,
|
||||
) -> Result<Vec<GitCommit>, String> {
|
||||
Err("Git history is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_modified_files(_vault_path: String) -> Result<Vec<ModifiedFile>, String> {
|
||||
pub fn get_modified_files(_vault_path: VaultPathArg) -> Result<Vec<ModifiedFile>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff(_vault_path: String, _path: String) -> Result<String, String> {
|
||||
pub fn get_file_diff(_vault_path: VaultPathArg, _path: NotePathArg) -> Result<String, String> {
|
||||
Err("Git diff is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_file_diff_at_commit(
|
||||
_vault_path: String,
|
||||
_path: String,
|
||||
_commit_hash: String,
|
||||
_vault_path: VaultPathArg,
|
||||
_path: NotePathArg,
|
||||
_commit_hash: CommitHashArg,
|
||||
) -> Result<String, String> {
|
||||
Err("Git diff is not available on mobile".into())
|
||||
}
|
||||
@@ -191,7 +260,7 @@ pub fn get_file_diff_at_commit(
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_vault_pulse(
|
||||
_vault_path: String,
|
||||
_vault_path: VaultPathArg,
|
||||
_limit: Option<usize>,
|
||||
_skip: Option<usize>,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
@@ -200,59 +269,59 @@ pub fn get_vault_pulse(
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit(_vault_path: String, _message: String) -> Result<String, String> {
|
||||
pub fn git_commit(_vault_path: VaultPathArg, _message: CommitMessageArg) -> Result<String, String> {
|
||||
Err("Git commit is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_last_commit_info(_vault_path: String) -> Result<Option<LastCommitInfo>, String> {
|
||||
pub fn get_last_commit_info(_vault_path: VaultPathArg) -> Result<Option<LastCommitInfo>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_pull(_vault_path: String) -> Result<GitPullResult, String> {
|
||||
pub async fn git_pull(_vault_path: VaultPathArg) -> Result<GitPullResult, String> {
|
||||
Err("Git pull is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_files(_vault_path: String) -> Result<Vec<String>, String> {
|
||||
pub fn get_conflict_files(_vault_path: VaultPathArg) -> Result<Vec<String>, String> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn get_conflict_mode(_vault_path: String) -> String {
|
||||
pub fn get_conflict_mode(_vault_path: VaultPathArg) -> String {
|
||||
"none".to_string()
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_resolve_conflict(
|
||||
_vault_path: String,
|
||||
_file: String,
|
||||
_strategy: String,
|
||||
_vault_path: VaultPathArg,
|
||||
_file: NotePathArg,
|
||||
_strategy: ConflictStrategyArg,
|
||||
) -> Result<(), String> {
|
||||
Err("Git conflict resolution is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_commit_conflict_resolution(_vault_path: String) -> Result<String, String> {
|
||||
pub fn git_commit_conflict_resolution(_vault_path: VaultPathArg) -> Result<String, String> {
|
||||
Err("Git conflict resolution is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_push(_vault_path: String) -> Result<GitPushResult, String> {
|
||||
pub async fn git_push(_vault_path: VaultPathArg) -> Result<GitPushResult, String> {
|
||||
Err("Git push is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, String> {
|
||||
pub async fn git_remote_status(_vault_path: VaultPathArg) -> Result<GitRemoteStatus, String> {
|
||||
Ok(GitRemoteStatus {
|
||||
branch: String::new(),
|
||||
has_remote: false,
|
||||
@@ -263,24 +332,139 @@ pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, S
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn git_discard_file(_vault_path: String, _relative_path: String) -> Result<(), String> {
|
||||
pub fn git_discard_file(
|
||||
_vault_path: VaultPathArg,
|
||||
_relative_path: NotePathArg,
|
||||
) -> Result<(), String> {
|
||||
Err("Git discard is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn is_git_repo(_vault_path: String) -> bool {
|
||||
pub fn is_git_repo(_vault_path: VaultPathArg) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
|
||||
pub fn init_git_repo(_vault_path: VaultPathArg) -> Result<(), String> {
|
||||
Err("Git init is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn clone_repo(_url: String, _local_path: String) -> Result<String, String> {
|
||||
pub fn clone_repo(_url: RemoteUrlArg, _local_path: LocalPathArg) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn vault_path(dir: &TempDir) -> String {
|
||||
dir.path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn note_path(dir: &TempDir, name: &str) -> String {
|
||||
dir.path().join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn create_initialized_vault() -> (TempDir, String) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join("note.md"), "# Note\n").unwrap();
|
||||
let vault = vault_path(&dir);
|
||||
init_git_repo(vault.clone()).unwrap();
|
||||
(dir, vault)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_git_commands_route_to_git_backend() {
|
||||
let (dir, vault) = create_initialized_vault();
|
||||
let note = note_path(&dir, "note.md");
|
||||
|
||||
assert!(is_git_repo(vault.clone()));
|
||||
|
||||
fs::write(dir.path().join("note.md"), "# Updated\n").unwrap();
|
||||
let modified = get_modified_files(vault.clone()).unwrap();
|
||||
assert!(modified.iter().any(|file| file.relative_path == "note.md"));
|
||||
|
||||
let diff = get_file_diff(vault.clone(), note.clone()).unwrap();
|
||||
assert!(diff.contains("# Updated"));
|
||||
|
||||
git_commit(vault.clone(), "Update note".to_string()).unwrap();
|
||||
let history = get_file_history(vault.clone(), note.clone()).unwrap();
|
||||
assert!(history.iter().any(|commit| commit.message == "Update note"));
|
||||
|
||||
let last_commit = get_last_commit_info(vault.clone()).unwrap().unwrap();
|
||||
assert!(!last_commit.short_hash.is_empty());
|
||||
|
||||
let commit_diff = get_file_diff_at_commit(
|
||||
vault.clone(),
|
||||
note.clone(),
|
||||
history.first().unwrap().hash.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(commit_diff.contains("# Updated"));
|
||||
|
||||
let pulse = get_vault_pulse(vault.clone(), Some(5), Some(0)).unwrap();
|
||||
assert!(!pulse.is_empty());
|
||||
|
||||
fs::write(dir.path().join("note.md"), "# Discard me\n").unwrap();
|
||||
git_discard_file(vault.clone(), "note.md".to_string()).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.path().join("note.md")).unwrap(),
|
||||
"# Updated\n"
|
||||
);
|
||||
|
||||
assert!(get_conflict_files(vault.clone()).unwrap().is_empty());
|
||||
assert_eq!(get_conflict_mode(vault.clone()), "none");
|
||||
assert!(
|
||||
git_resolve_conflict(vault.clone(), "note.md".to_string(), "invalid".to_string(),)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_git_repo_rejects_broad_personal_folders() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let documents = dir.path().join("Documents");
|
||||
fs::create_dir_all(&documents).unwrap();
|
||||
fs::write(documents.join("unrelated.txt"), "not a vault").unwrap();
|
||||
|
||||
let err = init_git_repo(documents.to_string_lossy().into_owned())
|
||||
.expect_err("expected Documents itself to be rejected before git init");
|
||||
|
||||
assert!(err.contains("dedicated vault folder"));
|
||||
assert!(!documents.join(".git").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_git_repo_allows_named_vault_subfolder_under_documents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("Documents").join("Tolaria");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("note.md"), "# Note\n").unwrap();
|
||||
let vault = vault.to_string_lossy().into_owned();
|
||||
|
||||
init_git_repo(vault.clone()).unwrap();
|
||||
|
||||
assert!(is_git_repo(vault));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_remote_commands_report_no_remote() {
|
||||
let (_dir, vault) = create_initialized_vault();
|
||||
|
||||
let pull = git_pull(vault.clone()).await.unwrap();
|
||||
assert_eq!(pull.status, "no_remote");
|
||||
|
||||
let push = git_push(vault.clone()).await.unwrap();
|
||||
assert_eq!(push.status, "error");
|
||||
|
||||
let status = git_remote_status(vault.clone()).await.unwrap();
|
||||
assert!(!status.has_remote);
|
||||
assert_eq!((status.ahead, status.behind), (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
18
src-tauri/src/commands/git_clone.rs
Normal file
18
src-tauri/src/commands/git_clone.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use super::expand_tilde;
|
||||
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub async fn clone_git_repo(url: String, local_path: String) -> Result<String, String> {
|
||||
let url = url.trim().to_string();
|
||||
let local_path = expand_tilde(&local_path).into_owned();
|
||||
|
||||
tokio::task::spawn_blocking(move || super::git::clone_repo(url, local_path))
|
||||
.await
|
||||
.map_err(|e| format!("Task panicked: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub async fn clone_git_repo(_url: String, _local_path: String) -> Result<String, String> {
|
||||
Err("Git clone is not available on mobile".into())
|
||||
}
|
||||
167
src-tauri/src/commands/memory.rs
Normal file
167
src-tauri/src/commands/memory.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use serde::Serialize;
|
||||
use std::process::Command;
|
||||
|
||||
const WEBKIT_AUX_PID_WINDOW: u32 = 512;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessMemoryEntry {
|
||||
pub pid: u32,
|
||||
pub parent_pid: u32,
|
||||
pub rss_bytes: u64,
|
||||
pub role: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProcessMemorySnapshot {
|
||||
pub current_pid: u32,
|
||||
pub total_rss_bytes: u64,
|
||||
pub entries: Vec<ProcessMemoryEntry>,
|
||||
}
|
||||
|
||||
struct ProcessRow {
|
||||
pid: u32,
|
||||
parent_pid: u32,
|
||||
rss_kib: u64,
|
||||
command: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_process_memory_snapshot() -> Result<ProcessMemorySnapshot, String> {
|
||||
let current_pid = std::process::id();
|
||||
let entries = collect_related_process_memory(current_pid)?;
|
||||
let total_rss_bytes = entries.iter().map(|entry| entry.rss_bytes).sum();
|
||||
|
||||
Ok(ProcessMemorySnapshot {
|
||||
current_pid,
|
||||
total_rss_bytes,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_related_process_memory(current_pid: u32) -> Result<Vec<ProcessMemoryEntry>, String> {
|
||||
let rows = read_process_rows()?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| related_process_entry(row, current_pid))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn related_process_entry(row: ProcessRow, current_pid: u32) -> Option<ProcessMemoryEntry> {
|
||||
let role = classify_related_process(&row, current_pid)?;
|
||||
Some(ProcessMemoryEntry {
|
||||
pid: row.pid,
|
||||
parent_pid: row.parent_pid,
|
||||
rss_bytes: row.rss_kib.saturating_mul(1024),
|
||||
role,
|
||||
command: row.command,
|
||||
})
|
||||
}
|
||||
|
||||
fn classify_related_process(row: &ProcessRow, current_pid: u32) -> Option<String> {
|
||||
if row.pid == current_pid {
|
||||
return Some("app".to_string());
|
||||
}
|
||||
if !is_nearby_webkit_auxiliary(row, current_pid) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if row.command.contains("WebKit.WebContent") {
|
||||
return Some("webkit-webcontent".to_string());
|
||||
}
|
||||
if row.command.contains("WebKit.GPU") {
|
||||
return Some("webkit-gpu".to_string());
|
||||
}
|
||||
if row.command.contains("WebKit.Networking") {
|
||||
return Some("webkit-networking".to_string());
|
||||
}
|
||||
Some("webkit".to_string())
|
||||
}
|
||||
|
||||
fn is_nearby_webkit_auxiliary(row: &ProcessRow, current_pid: u32) -> bool {
|
||||
row.pid > current_pid
|
||||
&& row.pid.saturating_sub(current_pid) <= WEBKIT_AUX_PID_WINDOW
|
||||
&& row.command.contains("com.apple.WebKit.")
|
||||
}
|
||||
|
||||
fn parse_process_row(line: &str) -> Option<ProcessRow> {
|
||||
let mut fields = line.split_whitespace();
|
||||
let pid = fields.next()?.parse().ok()?;
|
||||
let parent_pid = fields.next()?.parse().ok()?;
|
||||
let rss_kib = fields.next()?.parse().ok()?;
|
||||
let command = fields.collect::<Vec<_>>().join(" ");
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ProcessRow {
|
||||
pid,
|
||||
parent_pid,
|
||||
rss_kib,
|
||||
command,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
|
||||
let output = Command::new("ps")
|
||||
.args(["-axo", "pid=,ppid=,rss=,command="])
|
||||
.output()
|
||||
.map_err(|error| format!("Failed to sample process memory: {error}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("Failed to sample process memory with ps".to_string());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout.lines().filter_map(parse_process_row).collect())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn read_process_rows() -> Result<Vec<ProcessRow>, String> {
|
||||
Err("Process memory snapshots are only implemented on Unix platforms".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_ps_rows_with_spaced_commands() {
|
||||
let row = parse_process_row(" 42 1 1024 /System/WebKit WebContent").unwrap();
|
||||
|
||||
assert_eq!(row.pid, 42);
|
||||
assert_eq!(row.parent_pid, 1);
|
||||
assert_eq!(row.rss_kib, 1024);
|
||||
assert_eq!(row.command, "/System/WebKit WebContent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_nearby_webkit_auxiliaries() {
|
||||
let row = ProcessRow {
|
||||
pid: 120,
|
||||
parent_pid: 1,
|
||||
rss_kib: 10,
|
||||
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
classify_related_process(&row, 100),
|
||||
Some("webkit-webcontent".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unrelated_webkit_auxiliaries() {
|
||||
let row = ProcessRow {
|
||||
pid: 900,
|
||||
parent_pid: 1,
|
||||
rss_kib: 10,
|
||||
command: "/System/com.apple.WebKit.WebContent.xpc".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(classify_related_process(&row, 100), None);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user