Compare commits

...

247 Commits

Author SHA1 Message Date
lucaronin
2fbdafa6f2 fix: set git identity before remote connect 2026-04-27 18:59:23 +02:00
lucaronin
39f44b86aa fix: copy fenced code blocks exactly 2026-04-27 18:31:51 +02:00
lucaronin
cb3274cdbc fix: harden windows mcp setup 2026-04-27 17:51:25 +02:00
lucaronin
76d8bba332 fix: remove hidden Claude permission bypass 2026-04-27 16:46:32 +02:00
lucaronin
86853cf337 fix: allow Codex to edit active vault 2026-04-27 16:07:05 +02:00
lucaronin
9a292d2476 fix: map releases to Sentry 2026-04-27 15:27:31 +02:00
lucaronin
4a22b02810 test: harden locale catalog validation 2026-04-27 14:48:49 +02:00
github-actions[bot]
e4270cda3b Merge branch 'main' into main 2026-04-27 12:45:03 +00:00
lucaronin
9cb0de37a5 fix: parse crlf frontmatter 2026-04-27 14:31:51 +02:00
github-actions[bot]
2a794cb373 Merge branch 'main' into main 2026-04-27 11:37:50 +00:00
lucaronin
324af6b271 fix: normalize folder filtering across path separators 2026-04-27 13:27:20 +02:00
Disheng Qiu
926db0eeb5 feat: add lara-powered app localization 2026-04-27 13:06:59 +02:00
lucaronin
b1a97834c9 fix: keep raw editor full width 2026-04-27 11:54:15 +02:00
lucaronin
da13cb38ac fix: satisfy editor find command contract 2026-04-27 10:57:58 +02:00
lucaronin
dbf8ba5f40 feat: add editor find and replace 2026-04-27 10:57:58 +02:00
lucaronin
3e8a6e5d7c fix: render inline math while typing 2026-04-27 10:44:35 +02:00
lucaronin
8141644729 fix: revert note width modes 2026-04-27 10:13:55 +02:00
lucaronin
0d88c75718 Merge branches 'main', 'main', 'main' and 'main' of https://github.com/refactoringhq/tolaria 2026-04-27 09:42:36 +02:00
lucaronin
f8721f2a1b feat: add note width modes 2026-04-27 05:34:11 +02:00
lucaronin
fb39c6679a fix: handle invalid Windows save paths 2026-04-27 04:04:04 +02:00
lucaronin
f1bed131bf fix: refresh type state after type changes 2026-04-27 03:25:24 +02:00
lucaronin
257cf6ed02 fix: keep theme tooltip inside window 2026-04-27 02:20:28 +02:00
lucaronin
908daafaa1 feat: add basic file actions 2026-04-27 01:45:16 +02:00
lucaronin
d15437face test: cover consecutive IME composer input 2026-04-27 01:05:34 +02:00
lucaronin
30281f879d chore: ratchet codescene thresholds 2026-04-27 00:49:39 +02:00
lucaronin
4ef6edfb10 feat: preview vault images in app 2026-04-27 00:36:24 +02:00
lucaronin
918419e371 fix: respect macos control text bindings 2026-04-26 23:22:53 +02:00
lucaronin
2704002973 fix: remove note drag and drop flows 2026-04-26 22:51:08 +02:00
lucaronin
9265b8f117 fix: stabilize AI composer text editing 2026-04-26 22:11:54 +02:00
lucaronin
943fa854e1 fix: satisfy localization build 2026-04-26 19:39:56 +02:00
lucaronin
a2f8ba72a2 fix: complete app localization pass 2026-04-26 19:37:21 +02:00
lucaronin
1d546dde3d fix: allow note drops to insert wikilinks 2026-04-26 18:15:22 +02:00
lucaronin
63877e61ec fix: store window state in logical points 2026-04-26 17:54:41 +02:00
lucaronin
8896f1d451 fix: restore saved window size after launch 2026-04-26 17:54:41 +02:00
lucaronin
f523099854 feat: add vault loading skeleton 2026-04-26 17:35:09 +02:00
lucaronin
15f38f096f feat: support non-git vaults 2026-04-26 17:08:14 +02:00
lucaronin
cdd2ddec6c feat: persist main window placement 2026-04-26 15:45:34 +02:00
lucaronin
292d3739df fix: prevent editor handling note wikilink drops 2026-04-26 14:37:11 +02:00
lucaronin
1136c1948e fix: clarify macos release artifact names 2026-04-26 14:29:47 +02:00
lucaronin
8406042db1 fix: support native folder drops in AI inputs 2026-04-26 13:59:39 +02:00
lucaronin
f07cbbe0eb test: reflect cloned getting started vault in smoke 2026-04-26 12:28:01 +02:00
lucaronin
768da9f955 fix: prevent bottom bar wrap at narrow widths 2026-04-26 12:12:05 +02:00
lucaronin
f58e9d8930 fix: allow editor runtime styles under CSP 2026-04-26 11:31:41 +02:00
lucaronin
133242eff5 fix: keep compact status bar on one row 2026-04-26 10:33:16 +02:00
lucaronin
aebd2bea4a fix: refresh language settings draft 2026-04-26 09:46:11 +02:00
lucaronin
60238fbe78 fix: persist native language settings 2026-04-26 09:17:28 +02:00
lucaronin
5336236e79 test: cover native command wrappers 2026-04-26 08:39:56 +02:00
lucaronin
f9719f11b5 feat: add app localization foundation 2026-04-26 08:18:47 +02:00
lucaronin
e4083ded1a fix: detect claude cli on windows 2026-04-26 07:34:27 +02:00
lucaronin
65a89f102a fix: detect codex from macos shell paths 2026-04-26 06:48:35 +02:00
lucaronin
5b34ec2980 feat: publish intel mac builds 2026-04-26 06:07:33 +02:00
lucaronin
e579979829 fix: preserve composed ai input 2026-04-26 05:24:05 +02:00
lucaronin
c7edc71a97 feat: add note layout preference 2026-04-26 04:41:18 +02:00
lucaronin
5fd8f8fb40 feat: add markdown math support 2026-04-26 03:52:10 +02:00
lucaronin
694419d442 fix: disable native text suggestions 2026-04-26 03:01:10 +02:00
lucaronin
e82fb2a5c7 test: cover table cell paste regression 2026-04-26 02:58:46 +02:00
lucaronin
39234dcf52 fix: block unsafe editor links 2026-04-26 02:16:20 +02:00
lucaronin
36e8a62284 fix: isolate blocknote side-menu clicks 2026-04-26 01:20:11 +02:00
lucaronin
d622b91b81 docs: ratchet codescene thresholds 2026-04-26 00:31:55 +02:00
lucaronin
5a546b9a8b feat: add generic MCP config support 2026-04-26 00:15:22 +02:00
lucaronin
b8c1a094b6 fix: resolve ime selection typing 2026-04-25 23:50:04 +02:00
lucaronin
c2c56cfca5 fix: harden inline wikilink ime handling 2026-04-25 23:46:31 +02:00
lucaronin
449a62e31f fix: address unicode slug type check 2026-04-25 23:11:50 +02:00
lucaronin
7d6fd5cc51 fix: preserve unicode note title slugs 2026-04-25 23:08:30 +02:00
lucaronin
75ed6460ac feat: drop notes into editors as wikilinks 2026-04-25 22:31:57 +02:00
lucaronin
a3096c596f fix: reflow status bar at narrow widths 2026-04-25 21:59:10 +02:00
lucaronin
6105c7527c refactor: fix hook dependency lint warnings 2026-04-25 21:26:25 +02:00
lucaronin
92eee0d470 fix: keep editor selection stable around media blocks 2026-04-25 21:21:41 +02:00
lucaronin
f14fda09d5 fix: accept finder folder drops in ai composer 2026-04-25 20:41:56 +02:00
lucaronin
31d12d8cc8 test: stabilize smoke web server 2026-04-25 19:51:41 +02:00
lucaronin
6a002f0dc0 fix: guard missing inline input types 2026-04-25 19:23:00 +02:00
lucaronin
9d30d1165f fix: harden linux appimage startup 2026-04-25 18:54:38 +02:00
lucaronin
b709acd58a fix: type delete confirmation shortcuts 2026-04-25 18:23:37 +02:00
lucaronin
080e3498a7 fix: submit delete confirmation on enter 2026-04-25 18:20:32 +02:00
lucaronin
51010ca578 fix: show manual update feedback 2026-04-25 17:15:19 +02:00
lucaronin
15bc3a4701 fix: disable command palette text correction 2026-04-25 16:49:48 +02:00
lucaronin
3ebfa642fa fix: allow runtime stylesheet injection 2026-04-25 16:06:17 +02:00
lucaronin
8207b87c33 fix: include linux signatures in stable release 2026-04-25 14:39:19 +02:00
lucaronin
602a099ac3 fix: polish contribute dialog copy 2026-04-25 13:54:39 +02:00
lucaronin
2315122c4a fix: clear stale editor selection on note switch 2026-04-25 13:27:33 +02:00
lucaronin
ab4fcdbaae ci: include linux alpha updater signatures 2026-04-25 12:45:31 +02:00
lucaronin
a56cb8c287 fix: allow raw editor styles under CSP 2026-04-25 12:03:54 +02:00
lucaronin
b93e6d491a ci: test linux alpha release artifacts 2026-04-25 11:59:21 +02:00
lucaronin
3152ff2c6e fix: allow optional pending diff loaders 2026-04-25 11:46:59 +02:00
lucaronin
dd018cf98b fix: queue diff opening until tab activation 2026-04-25 11:46:59 +02:00
lucaronin
d0d9d90096 test: stabilize H1 wikilink smoke 2026-04-25 11:20:14 +02:00
lucaronin
944efada94 fix: load note windows without vault scan 2026-04-25 10:53:09 +02:00
lucaronin
ef4ad256e3 feat: add Refactoring support card 2026-04-25 10:31:26 +02:00
lucaronin
c427cd2492 fix: guard native file drops 2026-04-25 10:07:43 +02:00
lucaronin
151e993ab7 chore: ratchet codescene thresholds 2026-04-25 02:29:08 +02:00
lucaronin
60554d0b96 fix: handle IME composition in AI composer 2026-04-25 02:29:08 +02:00
lucaronin
4929f11b6d fix: harden windows release and git subprocesses 2026-04-25 00:58:49 +02:00
lucaronin
3f4e5b585f fix: refresh image asset scope after attachment writes 2026-04-25 00:19:57 +02:00
lucaronin
cd49c81bd3 feat: add status bar theme toggle 2026-04-24 23:58:26 +02:00
lucaronin
b9f3dc9e1a fix: refine contribute panel actions 2026-04-24 23:44:02 +02:00
lucaronin
122aba7878 fix: load current note in new window 2026-04-24 23:29:44 +02:00
lucaronin
05896bde19 fix: restore editor external link toolbar actions 2026-04-24 23:25:22 +02:00
lucaronin
87a933f39c fix: replace silent catch blocks with warnings 2026-04-24 22:44:36 +02:00
lucaronin
54c0efa3e4 feat: add dark mode foundation 2026-04-24 22:28:07 +02:00
lucaronin
bdde0c8943 fix: bound note prefetch cache by size 2026-04-24 21:53:47 +02:00
lucaronin
a474303eb9 fix: preserve back history after cmd-click wikilinks 2026-04-24 21:15:27 +02:00
lucaronin
4701485ee5 fix: keep empty title clicks in h1 2026-04-24 20:13:34 +02:00
lucaronin
135a8a0adf fix: handle nested release artifacts 2026-04-24 19:48:56 +02:00
lucaronin
0f5d7d5340 fix: restore folder boundary validation 2026-04-24 19:23:55 +02:00
lucaronin
d6b3c0aef3 feat: add windows desktop release support 2026-04-24 19:23:55 +02:00
lucaronin
f896c01829 fix: publish linux stable release artifacts 2026-04-24 18:53:43 +02:00
lucaronin
c3cff0c461 feat: replace feedback with contribute modal 2026-04-24 17:45:43 +02:00
lucaronin
8c7f9238cb fix: gate macos shortcut constants for linux clippy 2026-04-24 16:45:05 +02:00
lucaronin
38acebba7c feat: add linux desktop support 2026-04-24 16:25:36 +02:00
lucaronin
622977aeb8 fix: restore clone modal invoke typing 2026-04-24 15:18:50 +02:00
lucaronin
9ce1c6f854 refactor: restore clone modal code health 2026-04-24 15:13:24 +02:00
lucaronin
46865638cd fix: harden clone git repo flow 2026-04-24 14:56:17 +02:00
lucaronin
564ee0a387 test: cover native command wrappers 2026-04-24 14:44:01 +02:00
lucaronin
11655c0283 fix: harden inbox auto-advance merge 2026-04-24 14:35:54 +02:00
github-actions[bot]
c24fd30266 Merge branch 'main' into main 2026-04-24 12:04:53 +00:00
lucaronin
daa1e3e985 fix: keep ai composer responsive after image paste 2026-04-24 13:52:58 +02:00
github-actions[bot]
44c7f43e57 Merge branch 'main' into main 2026-04-24 11:43:53 +00:00
lucaronin
f2c83be4b3 fix: format MCP config tests 2026-04-24 13:32:30 +02:00
lucaronin
6104402bfb fix: write Claude Code MCP config to .claude.json 2026-04-24 13:22:33 +02:00
github-actions[bot]
b3744e3bf5 Merge branch 'main' into main 2026-04-24 10:43:45 +00:00
lucaronin
67c8598b87 fix: keep clone flows off the UI thread 2026-04-24 12:34:00 +02:00
github-actions[bot]
52620c80dc Merge branch 'main' into main 2026-04-24 10:27:33 +00:00
lucaronin
7e386ff70c docs: move contributing guide to repo root 2026-04-24 12:27:17 +02:00
github-actions[bot]
aba5550b8e Merge branch 'main' into main 2026-04-24 10:07:41 +00:00
Luca Rossi
309b816e6d Add custom funding URL to FUNDING.yml 2026-04-24 12:07:27 +02:00
github-actions[bot]
f552d8abff Merge branch 'main' into main 2026-04-24 10:02:39 +00:00
lucaronin
9f6779e9f1 docs: add contributing guide 2026-04-24 11:46:35 +02:00
github-actions[bot]
bddf5106bb Merge branch 'main' into main 2026-04-24 06:31:23 +00:00
lucaronin
ba5c165016 test: harden macos titlebar coverage 2026-04-24 08:20:30 +02:00
github-actions[bot]
39415c350f Merge branch 'main' into main 2026-04-24 06:14:23 +00:00
Brian Lee
499e481df8 Merge branches 'main', 'main', 'main' and 'main' of https://github.com/refactoringhq/tolaria 2026-04-24 15:07:00 +09:00
Brian Lee
843cedd14e fix: align auto-advance test bridge types 2026-04-24 15:06:13 +09:00
Brian Lee
05156ca793 feat: add inbox auto-advance setting 2026-04-24 15:04:08 +09:00
lucaronin
2308b269c8 docs: add ADRs for cache concurrency and git signing fallback (guard — from commits 42bb6c4, 6dbcc33) 2026-04-24 08:03:58 +02:00
lucaronin
b7f482bf27 fix: respect macos header double click action 2026-04-24 07:47:53 +02:00
lucaronin
33d7404d6c fix: keep blocknote tables editable after focus changes 2026-04-24 07:02:40 +02:00
lucaronin
0ecac2ab79 fix: allow unicode type filenames 2026-04-24 04:01:43 +02:00
lucaronin
7c9f2b0dda fix: guard editor selection churn around inline actions 2026-04-24 03:25:50 +02:00
lucaronin
24e317cfa6 fix: handle unreadable non-utf8 files gracefully 2026-04-24 03:02:03 +02:00
lucaronin
66c183fa61 fix: keep IME composition stable in editors 2026-04-24 02:50:12 +02:00
lucaronin
dacf9bb19b fix: guard note loading without active vault 2026-04-24 02:16:49 +02:00
lucaronin
3c92c200ab fix: sync onboarding vault handoff 2026-04-24 01:45:11 +02:00
lucaronin
61c3a3e21b docs: ratchet codescene thresholds 2026-04-24 01:39:28 +02:00
lucaronin
2e7c94d159 fix: register created onboarding vaults 2026-04-24 01:26:22 +02:00
lucaronin
98ade7411a fix: avoid duplicate onboarding vault writes 2026-04-24 01:23:16 +02:00
lucaronin
18776341f8 fix: persist onboarding vault selections 2026-04-24 01:08:13 +02:00
lucaronin
57bbcdbaef fix: format cache hardening helpers 2026-04-24 00:00:10 +02:00
lucaronin
42bb6c4b18 fix: harden vault cache updates 2026-04-24 00:00:10 +02:00
lucaronin
070cd3ac6d test: stabilize telemetry onboarding smoke 2026-04-23 23:11:46 +02:00
lucaronin
accc6270d3 fix: restore vault images and default folders 2026-04-23 23:11:46 +02:00
lucaronin
941f12aa1b test: clear vitest cache before coverage 2026-04-23 22:45:22 +02:00
lucaronin
4710a2d0a9 fix: reuse local smoke server by default 2026-04-23 22:27:38 +02:00
lucaronin
65a421215a fix: satisfy arrow ligature type checks 2026-04-23 22:14:20 +02:00
lucaronin
4ed0289989 Revert "test: simplify main entrypoint setup"
This reverts commit 4ad32faa7b.
2026-04-23 22:14:11 +02:00
lucaronin
4ad32faa7b test: simplify main entrypoint setup 2026-04-23 22:10:02 +02:00
lucaronin
c9623d5ca8 feat: add arrow ligatures in both editors 2026-04-23 22:06:01 +02:00
lucaronin
77286457bf fix: anchor settings default agent dropdown 2026-04-23 20:09:49 +02:00
lucaronin
5b8aa8da0d fix: guard pull refresh during unsaved editor edits 2026-04-23 19:34:15 +02:00
lucaronin
21b6d8984a fix: support symlinked vault notes 2026-04-23 18:36:45 +02:00
lucaronin
11fc123492 fix: guard folder picker after update install 2026-04-23 18:16:43 +02:00
lucaronin
d98fa94e14 fix: retry commits when signing helper is missing 2026-04-23 17:27:53 +02:00
lucaronin
add3c4075f fix: thread vault image path through tab swaps 2026-04-23 16:35:27 +02:00
lucaronin
fb0d550eb2 fix: make starter vault images portable 2026-04-23 16:31:36 +02:00
lucaronin
6adf7fd53b fix: capture react root errors in sentry 2026-04-23 15:34:39 +02:00
lucaronin
3b4d9599d2 fix: finish empty Claude Code runs 2026-04-23 14:15:15 +02:00
lucaronin
bb1b18686f fix: stabilize smoke dev server watch 2026-04-23 13:45:24 +02:00
lucaronin
6dbcc334bf fix: create repos despite commit signing 2026-04-23 13:26:01 +02:00
lucaronin
4944365ed7 fix: detect local Claude Code installs 2026-04-23 12:24:26 +02:00
lucaronin
c4c6bb6c57 fix: restore folder row toggle 2026-04-23 09:27:17 +02:00
lucaronin
35dd2dd47b docs: add/update ADR for note retargeting (guard — from commit 2c9361d7) 2026-04-23 08:07:56 +02:00
lucaronin
89141bae14 chore: ratchet codescene thresholds 2026-04-23 00:53:45 +02:00
lucaronin
2b9294f884 style: format note move backend 2026-04-23 00:41:04 +02:00
lucaronin
a756af54ec fix: keep note drop targets active during drag 2026-04-23 00:27:09 +02:00
lucaronin
b01b156dfb fix: align folder rows with sidebar items 2026-04-23 00:21:20 +02:00
lucaronin
2c9361d704 feat: retarget notes to types and folders 2026-04-22 23:56:00 +02:00
lucaronin
cea71a4fd9 Merge branch 'main' of https://github.com/refactoringhq/tolaria 2026-04-22 22:54:40 +02:00
lucaronin
8b73ef5836 feat: add sidebar folder rename and delete actions 2026-04-22 22:49:16 +02:00
lucaronin
a6a727a4c0 fix: align inspector create-type typing 2026-04-22 22:42:04 +02:00
lucaronin
bf13eed3ab fix: handle filename collisions in create flows 2026-04-22 22:37:01 +02:00
lucaronin
a27a70e552 fix: allow PostHog and Google Fonts in app CSP 2026-04-22 22:24:45 +02:00
lucaronin
ca92bf4f77 fix: restore keyboard shortcut typing 2026-04-22 21:22:35 +02:00
lucaronin
9fda0a67ec fix: make note renames crash-safe 2026-04-22 21:18:38 +02:00
lucaronin
0ffb7c65a9 Ignore history shortcuts while editing 2026-04-22 21:06:27 +02:00
lucaronin
633d9f1496 Add security policy and private reporting guidance 2026-04-22 20:46:24 +02:00
lucaronin
ba3d413b94 fix: recover from missing note paths 2026-04-22 20:18:26 +02:00
lucaronin
f3286922ad fix: satisfy clippy for MCP status 2026-04-22 19:28:58 +02:00
lucaronin
b0b476c9e5 fix: harden external AI tool setup 2026-04-22 19:18:52 +02:00
lucaronin
dac696b752 fix: guard toolbar popover anchors 2026-04-22 17:57:30 +02:00
lucaronin
cda87ee6c0 feat: add sidebar folder rename and delete actions 2026-04-22 17:16:56 +02:00
lucaronin
33324a5c79 fix: harden mcp bridge and vault boundaries 2026-04-22 17:14:42 +02:00
lucaronin
f28626bbfe fix: finish claude agent streams 2026-04-22 15:59:12 +02:00
lucaronin
1bc2e3d1e0 docs: add ADRs for startup and linkify lifecycles (guard — from commits 59268acd, bf442646) 2026-04-22 15:09:45 +02:00
lucaronin
8e6bdab3e6 docs: ratchet codescene thresholds 2026-04-22 00:31:54 +02:00
lucaronin
d52362ad9b fix: repair tiptap patch integrity 2026-04-22 00:21:46 +02:00
lucaronin
bf442646f5 fix: silence duplicate linkify init warnings 2026-04-22 00:05:32 +02:00
lucaronin
1b3a444368 fix: show welcome on fresh install without missing vault 2026-04-21 23:34:07 +02:00
lucaronin
c4da7f1f2f fix: narrow raw mode sync override 2026-04-21 19:04:02 +02:00
lucaronin
c5fe291a9a fix: sync inspector edits into raw editor 2026-04-21 19:04:02 +02:00
lucaronin
59268acd04 fix: guard empty vault path during startup 2026-04-21 19:04:01 +02:00
lucaronin
3e4e2ebdc6 refactor: simplify breadcrumb and note-list search ui 2026-04-21 19:03:11 +02:00
Luca Rossi
05375258a2 Rename 'Documentation' section to 'Tech Docs' 2026-04-21 18:06:30 +02:00
Luca Rossi
0c0354bfb8 Updated README badges 2026-04-21 18:05:54 +02:00
lucaronin
6023e95d5b fix: serialize vitest coverage files 2026-04-21 17:38:44 +02:00
lucaronin
88d5694353 fix: retry flaky vitest coverage teardown 2026-04-21 17:35:00 +02:00
lucaronin
db6b38843b fix: keep vitest coverage shards off workspace 2026-04-21 17:21:35 +02:00
lucaronin
ad9901bbf5 fix: stabilize coverage suite reruns 2026-04-21 17:08:43 +02:00
lucaronin
b785c53ef9 test: increase strategic coverage 2026-04-21 16:54:52 +02:00
lucaronin
8ae1ade220 test: cover live sidebar type picker sync 2026-04-21 15:16:39 +02:00
lucaronin
35b035643e fix: adapt save reload callback 2026-04-21 15:09:27 +02:00
lucaronin
d3d663af0b fix: reload vault entries on tauri startup 2026-04-21 15:02:42 +02:00
lucaronin
8f624350d8 test: cover journal vault scan 2026-04-21 12:37:46 +02:00
lucaronin
0e54173834 test: cover explicit journal visibility 2026-04-21 12:27:06 +02:00
lucaronin
a6f1524a78 ci: reject placeholder telemetry secrets 2026-04-21 11:06:16 +02:00
lucaronin
4cd5baeb55 fix: sync sidebar types with live vault state 2026-04-21 10:45:39 +02:00
lucaronin
fb1265e088 test: stabilize note list search timing 2026-04-21 10:14:18 +02:00
lucaronin
ca89662231 fix: sync raw editor frontmatter state 2026-04-21 10:06:54 +02:00
lucaronin
97f285e3cb ci: upload coverage to codecov 2026-04-21 10:06:54 +02:00
Luca Rossi
4bd4ef43dc Added release badge in README 2026-04-21 09:43:40 +02:00
Luca Rossi
be29880a3e Added walkthroughs to the README 2026-04-21 09:22:51 +02:00
lucaronin
7be5519f61 fix: sort alpha releases chronologically 2026-04-21 09:10:16 +02:00
lucaronin
8cefbe949a fix: refine derived inverse relationship labels 2026-04-21 09:08:25 +02:00
lucaronin
dd0a55b8e8 docs: add ADR for external vault refresh after pulls and agent edits (guard — from commit 0020ade6) 2026-04-21 08:03:02 +02:00
lucaronin
610276b1bc docs: ratchet codescene thresholds 2026-04-21 00:56:59 +02:00
lucaronin
e6c968e37e fix: satisfy note list keyboard build 2026-04-21 00:50:44 +02:00
lucaronin
367a66f32a fix: debounce note list search 2026-04-21 00:47:45 +02:00
lucaronin
6a4046915c fix: harden startup telemetry urls 2026-04-20 23:38:41 +02:00
lucaronin
ec8b637c84 fix: hide uncloned getting started vault 2026-04-20 23:04:34 +02:00
lucaronin
3ee42fb856 test: align create-note smoke placeholder 2026-04-20 22:13:19 +02:00
lucaronin
eacf05a9f8 fix: reload notes after agent edits 2026-04-20 22:04:38 +02:00
lucaronin
a199fad803 fix: widen editor focus block typing 2026-04-20 18:38:51 +02:00
lucaronin
e956c806e5 fix: keep editor sync from clobbering live inserts 2026-04-20 18:35:50 +02:00
lucaronin
7a6e3863ad fix: reopen active note after pull updates 2026-04-20 16:04:11 +02:00
lucaronin
e0e2dc4063 test: add pull refresh regression 2026-04-20 15:53:56 +02:00
lucaronin
0020ade6d7 fix: tighten pulled note refresh flow 2026-04-20 14:43:52 +02:00
lucaronin
503e482c7d fix: normalize inverse relationship labels 2026-04-20 12:49:57 +02:00
lucaronin
0d0e6af4c9 fix: keep vault menu remove action in row 2026-04-20 12:19:10 +02:00
lucaronin
dbaf361878 test: fix starter-vault app shell coverage 2026-04-20 11:32:44 +02:00
lucaronin
d3741c82f5 test: fix starter-vault shell regression 2026-04-20 11:20:27 +02:00
lucaronin
b90d2f8612 fix: reorder welcome onboarding actions 2026-04-20 11:04:29 +02:00
lucaronin
9536efee0e fix: persist AI panel chats across reopen 2026-04-20 10:28:48 +02:00
lucaronin
c86a6ab9c5 docs: add/update ADR for starter vault local-first remote flow (guard — from commit 374b4f66) 2026-04-20 10:28:00 +02:00
Luca Rossi
1d7c027700 Updated Tolaria Icon 2026-04-20 10:25:46 +02:00
lucaronin
74e50c64bb fix: hide stale getting started vault entries 2026-04-20 01:02:08 +02:00
lucaronin
8b97ef711f feat: refresh welcome screen branding 2026-04-20 00:45:14 +02:00
lucaronin
08855cee92 fix: defer git history during note switching 2026-04-20 00:09:52 +02:00
lucaronin
03042bb49c fix: normalize shipped telemetry secrets 2026-04-19 23:27:57 +02:00
lucaronin
cee4bef179 fix: harden shipped telemetry config 2026-04-19 23:16:34 +02:00
534 changed files with 53826 additions and 6792 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.89
AVERAGE_THRESHOLD=9.75
HOTSPOT_THRESHOLD=10.0
AVERAGE_THRESHOLD=9.88

View File

@@ -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
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
custom: https://refactoring.fm/

View File

@@ -10,6 +10,7 @@ Il workflow `ci.yml` esegue i seguenti check automatici:
### 2. Test Coverage
- Frontend: vitest con coverage reporting
- Upload automatico su Codecov dai report LCOV frontend + Rust
- Threshold configurabile in `vitest.config.ts`
### 3. Code Health (CodeScene)
@@ -40,6 +41,11 @@ CODESCENE_PROJECT_ID=<your-project-id>
Il PAT di CodeScene è lo stesso che usi localmente (~/.codescene/token).
Il project ID lo trovi nella dashboard CodeScene.
### Codecov Setup
- Installa/attiva il repo in Codecov una volta sola tramite GitHub App / import del repository.
- Nessun `CODECOV_TOKEN` richiesto in GitHub Actions: `ci.yml` usa OIDC (`id-token: write` + `use_oidc: true`).
- Il workflow carica `coverage/lcov.info` (Vitest) e `coverage/rust.lcov` (cargo-llvm-cov).
### Telemetry Secrets For Release Builds
Aggiungi anche questi secrets per i workflow `release.yml` e `release-stable.yml`:
@@ -97,8 +103,11 @@ codescene delta-analysis --base-revision origin/main
## Workflow Triggers
- **Push**: su `main` e branch `experiment/*`
- **Push**: su `main`
- **Pull Request**: verso `main`
- **Manuale**: `workflow_dispatch`
Nota: l'upload a Codecov gira su push a `main` e sulle PR dello stesso repository. Le PR da fork saltano l'upload per evitare problemi di permessi OIDC.
## Status Checks

View File

@@ -3,6 +3,13 @@ name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
@@ -78,10 +85,23 @@ jobs:
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
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
verbose: true
# OIDC avoids long-lived CODECOV_TOKEN secrets.
# ── 3. Code Health (CodeScene — Hotspot + Average Code Health gates) ──
# Enforces minimum floors on BOTH hotspot and average code health.
# Thresholds come from .codescene-thresholds so CI and local hooks match.
@@ -147,3 +167,63 @@ jobs:
- 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

View File

@@ -50,7 +50,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 +62,8 @@ jobs:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
@@ -127,6 +129,95 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -138,6 +229,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 }}
@@ -160,12 +252,245 @@ 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
- 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,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
)
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 or deb 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/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: 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
@@ -177,6 +502,54 @@ 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 "")
@@ -193,7 +566,7 @@ jobs:
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
@@ -206,9 +579,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
{
@@ -220,6 +626,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}"
}
}
}
@@ -238,6 +659,33 @@ 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/*.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/*/*.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
# ─────────────────────────────────────────────────────────────

View File

@@ -42,6 +42,21 @@ jobs:
output = subprocess.check_output(command, text=True).strip()
return [line for line in output.splitlines() if line]
alpha_pattern = re.compile(r"^alpha-v(\d{4}\.\d{1,2}\.\d{1,2})-alpha\.(\d+)$")
def parse_alpha_tag(tag: str) -> tuple[str, int] | None:
match = alpha_pattern.fullmatch(tag)
if not match:
return None
calendar_version, sequence = match.groups()
return calendar_version, int(sequence)
def alpha_version(calendar_version: str, sequence: int) -> str:
return f"{calendar_version}-alpha.{sequence}"
def alpha_tag(calendar_version: str, sequence: int) -> str:
return f"alpha-v{calendar_version}-alpha.{sequence:04d}"
existing_tags = [
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
if tag.startswith("alpha-v")
@@ -49,7 +64,8 @@ jobs:
if existing_tags:
tag = existing_tags[0]
version = tag.removeprefix("alpha-v")
parsed = parse_alpha_tag(tag)
version = alpha_version(*parsed) if parsed is not None else tag.removeprefix("alpha-v")
else:
today = datetime.now(timezone.utc).date()
stable_date = None
@@ -71,8 +87,8 @@ jobs:
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
version = f"{calendar_version}-alpha.{sequence}"
tag = f"alpha-v{version}"
version = alpha_version(calendar_version, sequence)
tag = alpha_tag(calendar_version, sequence)
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
display_version = (
@@ -105,6 +121,8 @@ jobs:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
@@ -170,6 +188,95 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -181,6 +288,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 }}
@@ -198,13 +306,245 @@ 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
- 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,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
)
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 or deb 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/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: 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
@@ -216,9 +556,60 @@ 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=$(git tag --list 'alpha-v*' --sort=-version:refname | head -n 1 || echo "")
PREV_TAG=$(python3 <<'PY'
import re
import subprocess
current_tag = '${{ needs.version.outputs.tag }}'
pattern = re.compile(r'^alpha-v(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)$')
output = subprocess.check_output(['git', 'tag', '--list', 'alpha-v*'], text=True).strip()
tags = [line for line in output.splitlines() if line and line != current_tag]
parsed_tags = []
for tag in tags:
match = pattern.fullmatch(tag)
if not match:
continue
year, month, day, sequence = map(int, match.groups())
parsed_tags.append(((year, month, day, sequence), tag))
print(max(parsed_tags)[1] if parsed_tags else '')
PY
)
if [ -z "$PREV_TAG" ]; then
NOTES=$(git log --oneline --no-merges -20)
else
@@ -232,7 +623,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
@@ -245,8 +636,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
{
@@ -256,7 +677,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}"
}
}
}
@@ -274,6 +711,32 @@ 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/*.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/*/*.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
# ─────────────────────────────────────────────────────────────

1
.gitignore vendored
View File

@@ -70,5 +70,6 @@ CODE-HEALTH-REPORT.md
*.key.pub
# Local environment variables (never commit)
.env
.env.local
.env.*.local

49
CONTRIBUTING.md Normal file
View 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 Tolarias `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 whats broken, suggest whats missing, and contribute improvements.
That means a lot. Thanks for helping build it.

View File

@@ -1,8 +1,8 @@
[![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865) [![CodeScene Average Code Health](https://codescene.io/projects/76865/status-badges/average-code-health)](https://codescene.io/projects/76865)
![Latest stable](https://img.shields.io/github/v/release/refactoringhq/tolaria?display_name=tag) [![CI](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/ci.yml) [![Build](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/refactoringhq/tolaria/actions/workflows/release.yml) [![Codecov](https://codecov.io/gh/refactoringhq/tolaria/graph/badge.svg?branch=main)](https://codecov.io/gh/refactoringhq/tolaria) [![CodeScene Hotspot Code Health](https://codescene.io/projects/76865/status-badges/hotspot-code-health)](https://codescene.io/projects/76865)
# 💧 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 Mac 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
@@ -12,6 +12,13 @@ Personally, I use it to **run my life** (hey 👋 [Luca here](http://x.com/lucar
<img width="1000" height="656" alt="1776506856823-CleanShot_2026-04-18_at_12 06 57_2x" src="https://github.com/user-attachments/assets/8aeafb0a-b236-43c2-a083-ec111f903c38" />
## Walkthroughs
You can find some Loom walkthroughs below — they are short and to the point:
- [How I Organize My Own Tolaria Workspace](https://www.loom.com/share/bb3aaffa238b4be0bd62e4464bca2528)
- [My Inbox Workflow](https://www.loom.com/share/dffda263317b4fa8b47b59cdf9330571)
- [How I Save Web Resources to Tolaria](https://www.loom.com/share/8a3c1776f801402ebbf4d7b0f31e9882)
## Principles
- 📑 **Files-first** — Your notes are plain markdown files. They're portable, work with any editor, and require no export step. Your data belongs to you, not to any app.
@@ -39,7 +46,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
@@ -54,13 +84,17 @@ Open `http://localhost:5173` for the browser-based mock mode, or run the native
pnpm tauri dev
```
## Documentation
## Tech Docs
- 📐 [ARCHITECTURE.md](docs/ARCHITECTURE.md) — System design, tech stack, data flow
- 🧩 [ABSTRACTIONS.md](docs/ABSTRACTIONS.md) — Core abstractions and models
- 🚀 [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 projects trademark policy.

55
SECURITY.md Normal file
View 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.

View File

@@ -57,6 +57,17 @@ 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`.
### VaultEntry
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`).
@@ -132,9 +143,22 @@ 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, PDFs, archives, other non-text files | Stays a normal vault file; previewable images open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
Image previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
### 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.
@@ -153,7 +177,7 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
└── 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. 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.
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
@@ -239,7 +263,10 @@ 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`.
- **Portable filename validation** (`vault/filename_rules.rs`): note filenames, folder names, and custom view filenames all reject Windows-reserved device names, invalid characters, and trailing dot/space suffixes so a vault created on macOS/Linux still clones and syncs cleanly on Windows.
- **Recoverable save failures** (`useEditorSave`, `vault/file.rs`): invalid platform path syntax is reported as a clear retryable save error, while the editor keeps the unsaved buffer intact for another attempt.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Surface (UI)
@@ -266,6 +293,14 @@ type SidebarSelection =
| { 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. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `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.
### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
@@ -273,7 +308,7 @@ type SidebarSelection =
- The selected `entry` is the neighborhood source note.
- The source note stays pinned at the top of the note list as a standard active row, not a special card.
- Outgoing relationship groups render first using the note's `relationships` map.
- Inverse groups (`Children`, `Events`, `Referenced By`) and `Backlinks` render after the outgoing groups.
- Inverse groups (`Children`, `Events`, `Referenced by`) and `Backlinks` render after the outgoing groups.
- Empty groups stay visible with count `0`.
- Notes may appear in multiple groups when multiple relationships are true; Neighborhood mode does not deduplicate them across sections.
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
@@ -296,11 +331,17 @@ type SidebarSelection =
- 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
The folder tree hides only the dedicated `type/` directory, since note types already have their own sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
6. Sorts by `modified_at` descending
7. Skips unparseable files with a warning log
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands refresh 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 and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary.
### Vault Caching
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
@@ -310,7 +351,7 @@ A `vault_health_check` command detects stray files in non-protected subfolders a
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)
@@ -384,7 +425,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 |
@@ -395,6 +436,8 @@ interface PulseCommit {
`useAutoSync` hook handles automatic git sync:
- 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
- Detects merge conflicts → opens `ConflictResolverModal`
- Tracks remote status (branch, ahead/behind via `git_remote_status`)
- Handles push rejection (divergence) → sets `pull_required` status
@@ -457,6 +500,15 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
- 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.
### 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.
### Formatting Surface Policy
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
@@ -466,6 +518,9 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- 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.
- `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.
- `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
@@ -473,22 +528,23 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
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)\ntoken → WikiLink node"]
E --> F["editor.replaceBlocks()\n→ rendered editor"]
C --> D["preProcessMathMarkdown(body)\n$...$ / $$...$$ → tokens"]
D --> E["tryParseMarkdownToBlocks()\n→ BlockNote block tree"]
E --> F["injectWikilinks + injectMathInBlocks\n tokens → schema nodes"]
F --> G["editor.replaceBlocks()\n→ rendered editor"]
style A fill:#f8f9fa,stroke:#6c757d,color:#000
style F fill:#d4edda,stroke:#28a745,color:#000
style G 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 placeholder tokens use ASCII sentinels with URI-encoded LaTeX 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 + serializeMathAwareBlocks()\nschema nodesMarkdown source"]
C --> D["prepend frontmatter yaml"]
D --> E["invoke('save_note_content')\n→ disk write"]
@@ -508,13 +564,37 @@ Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass
### Raw Editor Mode
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.
### 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.
## 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 (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.md)). The previous vault-authored theming system remains removed; theme mode is an installation-local app preference.
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**: Applies `data-theme` and `.dark` for shadcn/ui, while CodeMirror and editor-specific consumers derive any non-CSS-variable values from the same semantic contract
## 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
@@ -570,7 +650,7 @@ 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, note layout, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle
- One-time migration from localStorage (`configMigration.ts`)
### AI Guidance Files
@@ -598,15 +678,16 @@ 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 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
- `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
@@ -624,11 +705,13 @@ interface Settings {
analytics_enabled: boolean | null
anonymous_id: string | null
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
theme_mode: 'light' | 'dark' | null
ui_language: AppLocale | null
default_ai_agent: 'claude_code' | 'codex' | null
}
```
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. `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. `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.
## Telemetry
@@ -640,7 +723,8 @@ 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/lib/telemetry.ts`** — `initSentry()`, `teardownSentry()`, `initPostHog()`, `teardownPostHog()`, `trackEvent()`. Path scrubber via `beforeSend` hook. DSN/release/key from `VITE_SENTRY_DSN`, `VITE_SENTRY_RELEASE`, and `VITE_POSTHOG_KEY` env vars.
- **`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. `reinit_sentry()` for runtime toggle.
### Tauri Commands
@@ -667,6 +751,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. 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`.
- **`.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 version as `VITE_SENTRY_RELEASE`.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -32,6 +32,7 @@ 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)
- ✅ App settings: `zoom: 1.3` (machine-specific preference)
- ✅ App settings: `ui_language: "zh-CN"` (installation-specific UI language)
### No hardcoded exceptions
@@ -99,6 +100,7 @@ flowchart LR
| Frontmatter parsing | gray_matter | 0.2 |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
| 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 | - |
@@ -176,15 +178,20 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. 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.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, 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 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. Folder-backed lists also show non-Markdown files: previewable image binaries get an image indicator and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, 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-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.
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 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.
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 macOS uses for native menu clicks.
When Tolaria is launched from a Linux AppImage, `run()` also injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set that variable. This keeps the workaround scoped to bundled WebKitGTK launches that are prone to Fedora/Wayland DMA-BUF crashes without changing native package installs.
## Multi-Window (Note Windows)
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
@@ -199,8 +206,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)
- Secondary windows are sized 800×700 with overlay title bar
- 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; 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
@@ -211,9 +218,11 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too
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`
3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a file/search-only built-in tool list; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`. Both app-launched paths can edit the active vault without enabling dangerous permission bypass modes.
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
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, 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.
#### Agent Event Flow
```mermaid
@@ -305,13 +314,14 @@ 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)
- `~/.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), 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. Packaged builds resolve `mcp-server/` from the installed resource directory next to the executable before falling back to macOS `Resources`, Linux bundle paths, and AppImage paths. The `useMcpStatus` hook tracks whether the active vault is explicitly connected (`checking | installed | not_installed`).
### Architecture
@@ -330,7 +340,7 @@ flowchart TD
end
TAURI["Tauri (mcp.rs)"] -->|"spawn on startup"| MCP
TAURI -->|"auto-register"| CFG["~/.claude/mcp.json\n~/.cursor/mcp.json"]
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 +367,11 @@ 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 |
| `register_mcp(vault_path)` | Verifies Node.js, resolves the packaged `mcp-server/`, and writes Tolaria's explicit stdio entry to Claude Code, Cursor, and generic MCP configs on user request |
| `remove_mcp()` | Removes Tolaria's MCP entry from Claude Code, 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 killed on app exit via `RunEvent::Exit` handler. The same desktop layer now keeps the Tauri asset protocol scoped to the active vault instead of every filesystem path.
## Search
@@ -379,7 +390,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 hashed (via `DefaultHasher`) to produce a deterministic filename. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v13 (bumped on VaultEntry field changes to force full rescan). Cache replacement is best-effort: Tolaria writes a temp file, fsyncs it, and renames it into place only after a short-lived writer lock plus an on-disk fingerprint check confirm another window/process has not already refreshed the cache. Failures are logged and the app falls back to rebuilding from the filesystem.
`<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 +404,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 +412,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 (see [ADR-0081](adr/0081-internal-light-dark-theme-runtime.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 `data-theme` and the shadcn-compatible `.dark` class before React consumers render, with a localStorage mirror to avoid startup flash when dark mode is selected.
## 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 +445,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
- `zoom`: Float zoom level (0.81.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,9 +456,13 @@ 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
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.app>` 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 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.
`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.
@@ -453,9 +478,9 @@ 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`
3. The `clone_git_repo()` Tauri command runs `git clone` inside a blocking Tokio task so the Tauri window stays responsive during slow or failing clones
4. `git_push()` / `git_pull()` continue to use the same system git path
5. If auth fails, the raw git stderr is surfaced in the UI
5. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input
**Auth model:**
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
@@ -486,7 +511,8 @@ sequenceDiagram
participant MCP as MCP Server
participant U as User
T->>T: run_startup_tasks()<br/>(register MCP)
T->>T: apply Linux AppImage WebKit env override<br/>(AppImage only)
T->>T: run_startup_tasks()<br/>(migrate + seed only)
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
T->>A: App mounts
@@ -495,10 +521,10 @@ sequenceDiagram
A-->>U: WelcomeScreen
else Vault found
A->>VL: useVaultLoader fires
VL->>T: invoke('list_vault') → scan_vault_cached()
VL->>T: invoke('reload_vault') → sync active vault asset scope + scan_vault_cached()
T-->>VL: VaultEntry[]
VL->>T: invoke('get_modified_files')
VL->>T: useMcpStatus — register if needed
A->>T: useMcpStatus — check explicit MCP setup state
VL-->>A: entries ready
end
@@ -531,7 +557,11 @@ flowchart TD
AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"]
PULL --> PC{Result?}
PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"]
PC -->|Fast-forward| RV["reload vault"]
PC -->|Fast-forward| RV["reload vault + folders/views"]
RV --> TAB{"clean active tab?"}
TAB -->|Yes| RT["replace active tab\nwith fresh disk content"]
TAB -->|No| DONE["idle"]
RT --> DONE
PC -->|Up to date| DONE["idle"]
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
@@ -555,7 +585,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.
@@ -579,8 +611,9 @@ 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 |
| `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` |
| `getting_started.rs` | Clones and normalizes the public Getting Started starter vault |
@@ -595,12 +628,12 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `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 |
| `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,11 +645,15 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `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 |
| `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` | Sync the active vault asset scope, invalidate cache, and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `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 |
@@ -635,6 +672,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 |
@@ -675,8 +713,11 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `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 |
| `register_mcp_tools` | Register MCP in Claude/Cursor/generic config for the active vault |
| `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Cursor/generic config |
| `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Cursor/generic config |
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.
### Settings & Config
@@ -690,8 +731,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `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 refresh the active vault asset scope |
| `copy_image_to_vault` | Copy image file to `attachments/` and refresh the active vault 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 |
@@ -724,18 +765,21 @@ 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 |
| `useTheme` | Editor theme CSS vars and theme-mode bridge | Editor typography and app theme runtime |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
| `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 |
| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, 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 |
@@ -749,20 +793,24 @@ 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+[ / Cmd+] | Navigate back / forward (replaces tabs) |
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
| Cmd+19 | Switch to tab N |
| Cmd+[ / Cmd+] | Navigate back / forward |
| `[[` in editor | Open wikilink suggestion menu |
Selection-dependent note actions are wired through both the command palette and the native Note menu. 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.
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
- `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+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions
- `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()`
@@ -778,14 +826,19 @@ Every push to `main` triggers `.github/workflows/release.yml`:
```
push to main
→ version job: compute calendar alpha version YYYY.M.D-alpha.N
and a GitHub-sorted tag alpha-vYYYY.M.D-alpha.NNNN
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
→ if stable already uses today, advance alpha to the next calendar day so semver still increases
→ build job:
→ 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
→ publish GitHub prerelease alpha-v<version> named Tolaria Alpha YYYY.M.D.N
→ 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
→ publish alpha/latest.json
@@ -801,13 +854,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
```
@@ -815,7 +875,7 @@ push stable-vYYYY.M.D tag
### Versioning
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`.
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`. The GitHub release tag zero-pads the sequence as `alpha-vYYYY.M.D-alpha.NNNN` so GitHub release ordering remains chronological.
- If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches.
- The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time.
- This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha.
@@ -848,7 +908,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
@@ -869,7 +929,8 @@ sequenceDiagram
**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 so frontend Sentry events group by the shipped alpha or stable version.
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`

View File

@@ -8,6 +8,27 @@ 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
```
## Quick Start
```bash
@@ -42,8 +63,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
@@ -68,6 +89,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
@@ -111,13 +134,14 @@ 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
│ │ ├── 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 +157,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
│ │
@@ -165,7 +191,7 @@ tolaria/
│ │ ├── 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
│ │ ├── 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 +213,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,7 +261,7 @@ 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/ai_agents.rs` | Shared CLI-agent availability checks, safe-default Codex adapter, and stream normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
@@ -263,19 +290,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. |
| `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 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/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, 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 +338,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. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
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
@@ -380,7 +409,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 +417,11 @@ 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. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list)
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs` (keep Codex sandboxed with active-vault `workspace-write`; do not use the dangerous bypass unless an ADR explicitly designs a new mode)
### Work with external MCP setup
1. **Backend registration/status**: Edit `src-tauri/src/mcp.rs`; registration must verify Node.js first, resolve the packaged `mcp-server/` for macOS, Windows, Linux, and AppImage installs, and write an explicit stdio entry with `VAULT_PATH` plus `WS_UI_PORT=9711`
2. **Setup dialog copy/actions**: Edit `src/components/McpSetupDialog.tsx`; users should see the Node.js prerequisite and the manual config shape before Tolaria writes third-party config files
3. **Status hook/toasts**: Edit `src/hooks/useMcpStatus.ts` when setup, reconnect, disconnect, or failure messaging changes

View File

@@ -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

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0070"
title: "Starter vaults are local-first with explicit remote connection"
status: active
date: 2026-04-19
---
## Context
ADR-0046 moved the Getting Started vault to a public GitHub repo cloned at runtime, and ADR-0059 established that Tolaria should support valid local-only vaults without treating a missing remote as an error.
That still left one mismatch: a freshly cloned starter vault inherited the template repo's `origin` remote. New users therefore landed in a vault that looked remote-backed by default, even though the intended workflow was to explore locally first and only connect a personal remote later. Keeping the starter remote also risked accidental pushes to the public template repo and gave Tolaria no safe place to reject incompatible remotes before tracking started.
## Decision
**After cloning the public starter vault, Tolaria removes every configured git remote so the vault opens local-only by default.** Users connect a remote later through an explicit Add Remote flow exposed from the `No remote` status-bar chip and the command palette.
**The new `git_add_remote` backend is the only path for attaching a remote to an existing local-only vault.** It adds `origin`, fetches the remote, rejects incompatible or ahead histories, and only starts tracking when the remote is safe for the current local repo.
## Options considered
- **Strip starter-vault remotes and add an explicit connect flow** (chosen): preserves a local-first onboarding experience, matches ADR-0059's local-only model, and prevents accidental coupling to the public template repo. Cons: users who want sync must do one extra explicit step.
- **Keep the starter repo's remote attached**: simplest implementation, but it makes the template repo look like the user's real sync target and increases the risk of accidental pushes or confusing remote state.
- **Force remote replacement during onboarding**: guarantees a personal remote up front, but adds too much setup friction to the Getting Started path and weakens Tolaria's offline/local-first story.
## Consequences
- Fresh Getting Started vaults now behave like any other local-only vault: commit locally first, then opt into sync later.
- The app gains a dedicated Add Remote UX (`AddRemoteModal`) plus a backend connection path (`git_add_remote`) instead of overloading clone or commit flows.
- Remote attachment is safer: Tolaria can reject unrelated or incompatible histories before the vault starts tracking a remote.
- The starter repo remains a distribution source only, not an ongoing sync destination.
- Re-evaluate if Tolaria later needs a faster "publish this local starter vault to my own repo" flow that should prefill or streamline the Add Remote step.

View File

@@ -0,0 +1,48 @@
---
type: ADR
id: "0071"
title: "External vault updates reload derived state and reopen the clean active note"
status: active
date: 2026-04-21
---
## Context
ADR-0002 makes the filesystem the source of truth, and ADR-0043 keeps locally edited frontmatter reactive inside the running UI. But external vault mutations still had a gap. A `git pull` or an AI agent edit could change notes on disk while the app kept showing stale note-list state, stale derived relationships, or an editor surface that still rendered the pre-refresh BlockNote document.
The fix needed to satisfy a few constraints at once:
- refresh all vault-derived UI, not just the main note list
- preserve unsaved local edits instead of clobbering them with disk state
- reopen the active note from disk when it is safe, even if another file changed, because backlinks, inverse relationships, and other derived surfaces can depend on the whole vault
- handle the native editor case where an in-place file update requires a full tab reopen to show the fresh document reliably
## Decision
**All external vault mutations now reconcile through one shared refresh path that reloads vault-derived state and then conditionally reopens the active note from disk.**
Tolaria now routes post-pull refreshes and AI-agent file modifications through the same `refreshPulledVaultState()` helper.
That shared path does the following:
1. Reload `vault.entries`, folders, and saved views together.
2. If there is no active note, stop after the reload.
3. If the active note has unsaved local edits, keep the current editor buffer and do not replace it from disk.
4. Otherwise, find the refreshed `VaultEntry` for the active note and replace the active tab with freshly loaded disk content.
5. If the active file itself changed in place during the external update, close the tab before reopening it so BlockNote fully remounts onto the new document.
6. If the active file no longer exists after the reload, close the open tab state instead of leaving a stale editor behind.
## Options considered
- **Shared external-refresh reconciler** (chosen): one policy for pulls and agent edits, consistent vault-derived UI, and explicit protection for unsaved local edits. Cons: more coupling between sync flows and tab management.
- **Patch only the changed surfaces ad hoc**: smaller individual fixes, but high risk of drift between pull handling, agent handling, and future external-write paths.
- **Always force a full app-level reload**: simplest correctness story, but too disruptive and more likely to throw away user context unnecessarily.
## Consequences
- Any workflow that mutates the vault externally, such as git pulls or agent writes, should go through the shared refresh reconciler rather than reloading a single surface in isolation.
- Clean active notes now converge back to on-disk truth automatically after external updates.
- Unsaved local edits remain protected from external refreshes, even when the rest of the vault reloads.
- Folder, saved-view, backlink, and inverse-relationship surfaces stay aligned with the refreshed vault, not just the editor tab.
- Tolaria now treats "refresh after external mutation" as a first-class synchronization concern rather than a per-feature fix.
- Re-evaluate if the editor gains a reliable in-place document reset API, because that could remove the need for the close-and-reopen step when the active file itself changed.

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0072"
title: "Confirmed vault paths gate startup state"
status: active
date: 2026-04-22
---
## Context
Tolaria's startup path was assuming that any incoming `vaultPath` was authoritative immediately. In practice, boot can pass through transient empty paths and stale paths that no longer correspond to the persisted active vault. That produced two classes of regressions:
1. `useVaultLoader` fired `reload_vault` and `get_modified_files` before a real vault path existed, generating avoidable warnings and backend calls for `""`.
2. On fresh install or other non-persisted startup cases, a missing path could incorrectly render `vault-missing` instead of the intended welcome flow.
Tolaria's onboarding and vault-loading surfaces need the same invariant: only a confirmed vault identity should drive startup side effects or missing-vault error UI.
## Decision
**Tolaria now treats a vault path as authoritative at startup only after it is confirmed.** Vault-loading side effects no-op until the path is non-empty, and the `vault-missing` onboarding state is shown only when the missing path was the persisted active vault recorded in `load_vault_list`. Otherwise, startup falls back to `welcome`.
## Options considered
- **Option A** (chosen): gate startup effects and missing-vault UI on confirmed vault identity. This keeps boot deterministic, avoids empty-path backend calls, and preserves the product rule that fresh installs should land in Welcome rather than an error state.
- **Option B**: treat any startup `vaultPath` as authoritative immediately. Simpler branching, but it keeps the existing race where transient or stale paths trigger warnings and the wrong onboarding state.
- **Option C**: special-case each startup surface independently. Lower immediate churn, but it would duplicate boot logic and let `useOnboarding` and `useVaultLoader` drift again.
## Consequences
- `useVaultLoader` must guard all startup work behind a real non-empty vault path.
- `useOnboarding` must consult persisted vault state before deciding that a missing path represents a deleted active vault.
- Fresh installs, cleared vault lists, and other startup flows without a confirmed active vault should resolve to `welcome`, even if an initial path probe fails.
- Re-evaluate if Tolaria introduces deeper startup routing (for example multiple launch intents or restored workspaces) that needs a richer boot-state model than the current confirmed-path gate.

View File

@@ -0,0 +1,30 @@
---
type: ADR
id: "0073"
title: "Persistent linkify protocol registry across editor remounts"
status: active
date: 2026-04-22
---
## Context
Tolaria keeps a single editor shell alive while users swap notes and toggle between BlockNote and raw mode. The upstream BlockNote/Tiptap link stack assumes linkify protocol registration is effectively one-shot per editor lifetime, and Tiptap's link extension resets that registry on destroy.
In Tolaria's lifecycle, that behavior caused duplicate `linkifyjs: already initialized` warnings during note-open and editor-remount flows. The problem is cross-cutting: BlockNote and Tiptap both participate, and the failure only disappears when protocol registration survives teardown/remount cycles instead of being repeated opportunistically.
## Decision
**Tolaria patches the upstream BlockNote and Tiptap link packages so custom linkify protocols are pre-registered once per app runtime and are not reset on editor teardown.** The patched packages coordinate through `globalThis` flags, and Tolaria tracks them via `pnpm` patched dependencies rather than ad hoc runtime monkey-patching inside app code.
## Options considered
- **Option A** (chosen): maintain explicit `pnpm` patches for the affected upstream packages, pre-register the needed protocols once, and preserve the registry across remounts. This matches Tolaria's persistent editor shell and keeps the behavior deterministic in both dev and packaged builds.
- **Option B**: keep upstream behavior and tolerate or suppress the warnings locally. Lower maintenance, but it leaves editor lifecycle correctness dependent on noisy duplicate initialization and makes future regressions harder to reason about.
- **Option C**: add Tolaria-side runtime monkey-patches around editor mount/unmount. Avoids vendoring patches, but spreads dependency-specific lifecycle logic into application code and is more fragile across package upgrades.
## Consequences
- `pnpm-workspace.yaml` now treats the relevant BlockNote and Tiptap link packages as patched dependencies, so upgrades must preserve or consciously replace those patches.
- Editor teardown in Tolaria must not assume ownership of the global linkify protocol registry.
- Smoke coverage for note open, editor remount, and raw-mode toggling must stay in place because the failure mode is lifecycle-specific rather than feature-specific.
- Re-evaluate this ADR if upstream BlockNote/Tiptap gains a supported lifecycle-safe protocol-registration model that makes the Tolaria patches unnecessary.

View File

@@ -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.

View 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.

View File

@@ -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.

View 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.

View File

@@ -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.

View 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.

View File

@@ -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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View File

@@ -66,9 +66,9 @@ 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 |
| [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active |
@@ -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 |
@@ -125,3 +125,18 @@ proposed → active → superseded
| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active |
| [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 |
| [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 |

View File

@@ -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[] = []

View File

@@ -7,17 +7,48 @@
<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';
function normalizeTheme(value) {
return value === 'dark' || value === 'light' ? value : null;
}
function applyTheme(value) {
var mode = normalizeTheme(value) || 'light';
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>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>

406
lara.lock Normal file
View File

@@ -0,0 +1,406 @@
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: 68efcb7e847b1a9d472baa609824be80
command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca
command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf
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.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.leftLayout: 5046a7166675e2d0175b9da3befdcaca
command.view.centerLayout: 3bc6759c005178aae519aa3dd227cb74
command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e
command.view.newAiChat: 1b0a886d6e77f628ec96c2d71cdb0a9b
command.view.toggleBacklinks: b3c4be2d2c07bb8df181355a2c3658a7
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.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.releaseChannel: 2da37055e15a217f18bd403922085c3f
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.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.inactiveThreshold: d28cb60d1e70e020ae8d1294e32dd474
settings.titles.title: 761443f70a5067ecf015c6fb3fae9cef
settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c
settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67
settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e
settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47
settings.aiAgents.description: a13222e67eb121676ff6dfc7b085f02d
settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24
settings.aiAgents.installed: 73329564760013a7824ff9d5d1af91ff
settings.aiAgents.missing: ea21841da70e6405af19fabc4ff8bdd9
settings.aiAgents.ready: 909a648c713be25fcd050725d0a41e37
settings.aiAgents.notInstalled: 0ac3f76ef78bfdbab6331731ee56ba22
settings.workflow.title: 24f47cdbe9ddba774a7cc53e51d9032e
settings.workflow.description: aa9a07539b87cf407c3edc8a22732d45
settings.workflow.explicit: 54a5b5c27937d25271c3127d093e8361
settings.workflow.explicitDescription: ce523427b5dd0f9895f63d1fbb90ad24
settings.workflow.autoAdvance: 9ed337f5bc61603d19a1ef35f3a3a243
settings.workflow.autoAdvanceDescription: ae58ad8abf03df7cbdae8c3a725258f7
settings.privacy.title: 0dd6c14e00cc9f45a68c1bca88d1317d
settings.privacy.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
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.deleteView: c8f4f9cd8ff820f955474e5c9f70ff39
sidebar.action.customizeSections: 050a134cad1e9c6f04abe5d635592703
sidebar.action.renameSection: 8936914051df04e7550d0eeb4a31e0e0
sidebar.action.customizeIconColor: 9a2f685c74d261ab483ef00dee5314fe
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.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.centerLayout: 3fabf7e9b285eeec90704d271f1049b7
editor.toolbar.leftLayout: acab6c8612816e012ff9f4220a42e485
editor.toolbar.removeFavorite: 7188286e36fc6c1892dd53aaa54237fb
editor.toolbar.addFavorite: 910885435dbc44a22b68cb45c79e4a7e
editor.toolbar.markUnorganized: 83fb1b23a3609fab493737fe7af0a198
editor.toolbar.markOrganized: 9d170c916afae3d7a82456838728ffa5
editor.toolbar.noDiff: b2c4189f90648aaeb5cd2e81f9bd8d94
editor.toolbar.loadingDiff: 430386d6aae3570fd399a133e41c7372
editor.toolbar.showDiff: 08d579de8d3abdcdfce0953a797362c6
editor.toolbar.openAi: d2e93006570a66709c8ce0dc27082ef1
editor.toolbar.closeAi: cd46973ef73076d612dff9903ce54498
editor.toolbar.restoreArchived: 1bff5cf4943af64c05b2e9aeadccbc84
editor.toolbar.archive: 63dc964c32e715217b49f8739d49636b
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
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.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.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.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.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
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.jaJP: f32ced6a9ba164c4b3c047fd1d7c882e
locale.koKR: d0bdb3cde477d82e766da05ebda50ccb

30
lara.yaml Normal file
View File

@@ -0,0 +1,30 @@
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
- ja-JP
- ko-KR
files:
json:
include:
- "src/lib/locales/[locale].json"
exclude: []
lockedKeys: []
ignoredKeys: []

View File

@@ -6,8 +6,10 @@ import os from 'node:os'
import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { evaluateBridgeRequest } from './ws-bridge.js'
let tmpDir
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
@@ -79,6 +81,17 @@ describe('getNote', () => {
{ code: 'ENOENT' }
)
})
it('should reject absolute paths outside the vault', async () => {
await assertRejectsOutsideVault('laputa-mcp-outside-', outsideNote => outsideNote)
})
it('should reject traversal paths outside the vault', async () => {
await assertRejectsOutsideVault(
'laputa-mcp-traversal-',
outsideNote => path.relative(tmpDir, outsideNote),
)
})
})
describe('searchNotes', () => {
@@ -142,3 +155,53 @@ describe('vaultContext', () => {
assert.equal(ctx.noteCount, 3)
})
})
describe('evaluateBridgeRequest', () => {
it('accepts loopback UI requests from trusted origins', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'ui',
origin: 'http://localhost:5202',
remoteAddress: '127.0.0.1',
}),
{ ok: true, reason: null },
)
})
it('rejects browser origins on the tool bridge', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'tool',
origin: 'https://evil.example',
remoteAddress: '127.0.0.1',
}),
{ ok: false, reason: 'browser origins are not allowed on the tool bridge' },
)
})
it('rejects non-loopback clients even without an origin', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'ui',
origin: undefined,
remoteAddress: '192.168.1.10',
}),
{ ok: false, reason: 'non-local client' },
)
})
})
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix))
const outsideNote = path.join(outsideDir, 'outside.md')
try {
await fs.writeFile(outsideNote, '# Outside\n')
await assert.rejects(
() => getNote(tmpDir, resolveNotePath(outsideNote)),
{ message: ACTIVE_VAULT_ERROR },
)
} finally {
await fs.rm(outsideDir, { recursive: true, force: true })
}
}

View File

@@ -6,6 +6,8 @@ import fs from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
/**
* Recursively find all .md files under a directory.
* @param {string} dir
@@ -15,17 +17,28 @@ export async function findMarkdownFiles(dir) {
const results = []
const items = await fs.readdir(dir, { withFileTypes: true })
for (const item of items) {
if (item.name.startsWith('.')) continue
const full = path.join(dir, item.name)
if (item.isDirectory()) {
results.push(...await findMarkdownFiles(full))
} else if (item.name.endsWith('.md')) {
results.push(full)
}
await collectMarkdownFile(results, dir, item)
}
return results
}
async function resolveVaultNotePath(vaultPath, notePath) {
const vaultRoot = await fs.realpath(vaultPath)
const requestedPath = resolveRequestedNotePath(vaultRoot, notePath)
const noteRealPath = await fs.realpath(requestedPath)
const relativePath = path.relative(vaultRoot, noteRealPath)
if (!isVaultRelativePath(relativePath)) {
throw new Error(ACTIVE_VAULT_ERROR)
}
return {
vaultRoot,
noteRealPath,
relativePath,
}
}
/**
* Read a note with parsed frontmatter and content.
* @param {string} vaultPath
@@ -33,11 +46,14 @@ export async function findMarkdownFiles(dir) {
* @returns {Promise<{path: string, frontmatter: Record<string, unknown>, content: string}>}
*/
export async function getNote(vaultPath, notePath) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
const raw = await fs.readFile(absPath, 'utf-8')
const {
noteRealPath,
relativePath,
} = await resolveVaultNotePath(vaultPath, notePath)
const raw = await fs.readFile(noteRealPath, 'utf-8')
const parsed = matter(raw)
return {
path: path.relative(vaultPath, absPath),
path: relativePath,
frontmatter: parsed.data,
content: parsed.content.trim(),
}
@@ -59,18 +75,15 @@ export async function searchNotes(vaultPath, query, limit = 10) {
if (results.length >= limit) break
const content = await fs.readFile(filePath, 'utf-8')
const filename = path.basename(filePath, '.md')
const titleMatch = extractTitle(content, filename)
const matches = titleMatch.toLowerCase().includes(q) || content.toLowerCase().includes(q)
if (!matchesSearchQuery(titleMatch, content, q)) continue
if (matches) {
const snippet = extractSnippet(content, q)
results.push({
path: path.relative(vaultPath, filePath),
title: titleMatch,
snippet,
})
}
const snippet = extractSnippet(content, q)
results.push({
path: path.relative(vaultPath, filePath),
title: titleMatch,
snippet,
})
}
return results
@@ -88,47 +101,92 @@ export async function vaultContext(vaultPath) {
const notesWithMtime = []
for (const filePath of files) {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const type = parsed.data.type || parsed.data.is_a || null
const { topFolder, note, type } = await readVaultContextNote(vaultPath, filePath)
if (type) typesSet.add(type)
const rel = path.relative(vaultPath, filePath)
const topFolder = rel.split(path.sep)[0]
if (topFolder !== rel) foldersSet.add(topFolder + '/')
const stat = await fs.stat(filePath)
notesWithMtime.push({
path: rel,
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
})
if (topFolder) foldersSet.add(topFolder)
notesWithMtime.push(note)
}
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
// Read config files for AI agent context
const configFiles = {}
try {
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
const agentsContent = await fs.readFile(agentsPath, 'utf-8')
configFiles.agents = agentsContent
} catch {
// config/agents.md may not exist yet
}
return {
types: [...typesSet].sort(),
noteCount: files.length,
folders: [...foldersSet].sort(),
recentNotes,
configFiles,
configFiles: await readConfigFiles(vaultPath),
vaultPath,
}
}
// --- Helpers ---
async function collectMarkdownFile(results, dir, item) {
if (item.name.startsWith('.')) return
const full = path.join(dir, item.name)
if (item.isDirectory()) {
results.push(...await findMarkdownFiles(full))
return
}
if (item.name.endsWith('.md')) {
results.push(full)
}
}
function resolveRequestedNotePath(vaultRoot, notePath) {
if (path.isAbsolute(notePath)) return notePath
return path.resolve(vaultRoot, notePath)
}
function isVaultRelativePath(relativePath) {
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
function matchesSearchQuery(title, content, query) {
return title.toLowerCase().includes(query) || content.toLowerCase().includes(query)
}
async function readVaultContextNote(vaultPath, filePath) {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const rel = path.relative(vaultPath, filePath)
const topFolder = extractTopFolder(rel)
const stat = await fs.stat(filePath)
const type = parsed.data.type || parsed.data.is_a || null
return {
topFolder,
type,
note: {
path: rel,
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
},
}
}
function extractTopFolder(relativePath) {
const topFolder = relativePath.split(path.sep)[0]
return topFolder === relativePath ? null : `${topFolder}/`
}
async function readConfigFiles(vaultPath) {
const configFiles = {}
try {
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
configFiles.agents = await fs.readFile(agentsPath, 'utf-8')
} catch {
// config/agents.md may not exist yet
}
return configFiles
}
/**
* Extract title from markdown content (first H1 or frontmatter title).
* @param {string} content

View File

@@ -28,6 +28,12 @@ import {
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'
const TRUSTED_UI_ORIGINS = new Set([
'tauri://localhost',
'http://tauri.localhost',
'https://tauri.localhost',
])
/** @type {WebSocketServer | null} */
let uiBridge = null
@@ -71,6 +77,52 @@ async function handleMessage(data) {
}
}
export function isLoopbackAddress(remoteAddress) {
return remoteAddress === '127.0.0.1'
|| remoteAddress === '::1'
|| remoteAddress === '::ffff:127.0.0.1'
}
export function isTrustedUiOrigin(origin) {
if (!origin) return true
if (TRUSTED_UI_ORIGINS.has(origin)) return true
return /^http:\/\/(?:localhost|127\.0\.0\.1):\d+$/u.test(origin)
}
export function evaluateBridgeRequest({ bridgeType, origin, remoteAddress }) {
if (!isLoopbackAddress(remoteAddress)) {
return { ok: false, reason: 'non-local client' }
}
if (bridgeType === 'tool' && origin) {
return { ok: false, reason: 'browser origins are not allowed on the tool bridge' }
}
if (bridgeType === 'ui' && !isTrustedUiOrigin(origin)) {
return { ok: false, reason: 'untrusted UI origin' }
}
return { ok: true, reason: null }
}
function verifyBridgeRequest(bridgeType) {
return (info, done) => {
const verdict = evaluateBridgeRequest({
bridgeType,
origin: info.origin,
remoteAddress: info.req.socket.remoteAddress,
})
if (!verdict.ok) {
console.error(`[ws-bridge] Rejected ${bridgeType} bridge client: ${verdict.reason}`)
done(false, 403, 'Forbidden')
return
}
done(true)
}
}
/**
* Attempt to start the UI bridge WebSocket server.
* Returns a Promise that resolves to the WebSocketServer or null if the port
@@ -89,8 +141,11 @@ export function startUiBridge(port = WS_UI_PORT) {
resolve(null)
})
httpServer.listen(port, () => {
const wss = new WebSocketServer({ server: httpServer })
httpServer.listen(port, LOOPBACK_HOST, () => {
const wss = new WebSocketServer({
server: httpServer,
verifyClient: verifyBridgeRequest('ui'),
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
// Relay: when a client sends a message, broadcast to all OTHER clients.
@@ -109,7 +164,11 @@ export function startUiBridge(port = WS_UI_PORT) {
}
export function startBridge(port = WS_PORT) {
const wss = new WebSocketServer({ port })
const wss = new WebSocketServer({
port,
host: LOOPBACK_HOST,
verifyClient: verifyBridgeRequest('tool'),
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] Client connected (vault: ${VAULT_PATH})`)
@@ -126,7 +185,7 @@ export function startBridge(port = WS_PORT) {
ws.on('close', () => console.error('[ws-bridge] Client disconnected'))
})
console.error(`[ws-bridge] Listening on ws://localhost:${port}`)
console.error(`[ws-bridge] Listening on ws://${LOOPBACK_HOST}:${port}`)
return wss
}

View File

@@ -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/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.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/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:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "node scripts/run-vitest-coverage.mjs",
@@ -72,6 +75,7 @@
"unicode-emoji-json": "^0.8.0"
},
"devDependencies": {
"@translated/lara-cli": "^1.3.2",
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.58.2",
"@tauri-apps/cli": "^2.10.0",

View File

@@ -1,8 +1,6 @@
diff --git a/dist/defaultBlocks-DE5GNdJH.js b/dist/defaultBlocks-DE5GNdJH.js
index b2761001278486a8b2ac1d10c82420b4994e96d9..fbf4f2f450ed1351d64adeb16263ceacf9ee393c 100644
--- a/dist/defaultBlocks-DE5GNdJH.js
+++ b/dist/defaultBlocks-DE5GNdJH.js
@@ -2761,7 +2761,7 @@ const B = new Ze("SuggestionMenuPlugin"), _n = k(({ editor: e }) => {
@@ -2761,7 +2761,7 @@
if (a === s) {
const c = r.state.doc;
for (const l of t) {
@@ -11,3 +9,84 @@ index b2761001278486a8b2ac1d10c82420b4994e96d9..fbf4f2f450ed1351d64adeb16263ceac
if (l === u)
return r.dispatch(r.state.tr.insertText(i)), r.dispatch(
r.state.tr.setMeta(B, {
--- a/src/editor/managers/ExtensionManager/extensions.ts
+++ b/src/editor/managers/ExtensionManager/extensions.ts
@@ -84,7 +84,8 @@
}).configure({
defaultProtocol: DEFAULT_LINK_PROTOCOL,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
- protocols: LINKIFY_INITIALIZED ? [] : VALID_LINK_PROTOCOLS,
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
+ protocols: [],
}),
...(Object.values(editor.schema.styleSpecs).map((styleSpec) => {
return styleSpec.implementation.mark.configure({
--- a/dist/blocknote.js
+++ b/dist/blocknote.js
@@ -2037,7 +2037,9 @@
]
})
);
-let fe = !1;
+const bnLinkifyProtocolFlag = "__tolariaBlockNoteLinkifyProtocolsRegistered";
+const bnGlobalObject = typeof globalThis > "u" ? void 0 : globalThis;
+let fe = Boolean(bnGlobalObject && bnGlobalObject[bnLinkifyProtocolFlag]);
function mn(o, e) {
const t = [
I.ClipboardTextSerializer,
@@ -2062,7 +2064,8 @@
}).configure({
defaultProtocol: Ct,
// only call this once if we have multiple editors installed. Or fix https://github.com/ueberdosis/tiptap/issues/5450
- protocols: fe ? [] : Bt
+ // Tolaria pre-registers BlockNote's non-native protocols before linkify initializes.
+ protocols: []
}),
...Object.values(o.schema.styleSpecs).map((n) => n.implementation.mark.configure({
editor: o
@@ -2112,7 +2115,7 @@
),
Do(o)
];
- return fe = !0, t;
+ return fe = !0, bnGlobalObject && (bnGlobalObject[bnLinkifyProtocolFlag] = !0), t;
}
function kn(o, e) {
const t = [
--- a/src/extensions/TableHandles/TableHandles.ts
+++ b/src/extensions/TableHandles/TableHandles.ts
@@ -572,9 +572,14 @@
const tableBody = this.tableElement!.querySelector("tbody");
if (!tableBody) {
- throw new Error(
- "Table block does not contain a 'tbody' HTML element. This should never happen.",
- );
+ this.state.show = false;
+ this.state.showAddOrRemoveRowsButton = false;
+ this.state.showAddOrRemoveColumnsButton = false;
+ this.state.rowIndex = undefined;
+ this.state.colIndex = undefined;
+ this.state.referencePosCell = undefined;
+ this.emitUpdate();
+ return;
}
if (
--- a/dist/TrailingNode-CxM966vN.js
+++ b/dist/TrailingNode-CxM966vN.js
@@ -1746,10 +1746,10 @@
);
this.state.rowIndex !== void 0 && this.state.colIndex !== void 0 && (this.state.rowIndex >= e && (this.state.rowIndex = e - 1), this.state.colIndex >= t && (this.state.colIndex = t - 1));
const o = this.tableElement.querySelector("tbody");
- if (!o)
- throw new Error(
- "Table block does not contain a 'tbody' HTML element. This should never happen."
- );
+ if (!o) {
+ this.state.show = !1, this.state.showAddOrRemoveRowsButton = !1, this.state.showAddOrRemoveColumnsButton = !1, this.state.rowIndex = void 0, this.state.colIndex = void 0, this.state.referencePosCell = void 0, this.emitUpdate();
+ return;
+ }
if (this.state.rowIndex !== void 0 && this.state.colIndex !== void 0) {
const i = o.children[this.state.rowIndex].children[this.state.colIndex];
i ? this.state.referencePosCell = i.getBoundingClientRect() : (this.state.rowIndex = void 0, this.state.colIndex = void 0);

View File

@@ -0,0 +1,101 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index b7357fa..c2c59cb 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -36,6 +36,16 @@ var import_core = require("@tiptap/core");
var import_state = require("@tiptap/pm/state");
var import_linkifyjs = require("linkifyjs");
+var PRE_REGISTERED_PROTOCOLS = ["tel", "callto", "sms", "cid", "xmpp"];
+var linkifyProtocolFlag = "__tolariaTiptapLinkifyProtocolsRegistered";
+var linkifyGlobalObject = typeof globalThis === "undefined" ? void 0 : globalThis;
+if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) {
+ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => {
+ (0, import_linkifyjs.registerCustomProtocol)(protocol);
+ });
+ linkifyGlobalObject[linkifyProtocolFlag] = true;
+}
+
// src/helpers/whitespace.ts
var UNICODE_WHITESPACE_PATTERN = "[\0- \xA0\u1680\u180E\u2000-\u2029\u205F\u3000]";
var UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN);
@@ -253,7 +263,8 @@ var Link = import_core3.Mark.create({
});
},
onDestroy() {
- (0, import_linkifyjs3.reset)();
+ // Tolaria keeps a single editor shell alive across note swaps, so linkify's
+ // protocol registry must survive editor teardown and remount cycles.
},
inclusive() {
return this.options.autolink;
@@ -472,4 +483,4 @@ var index_default = Link;
isAllowedUri,
pasteRegex
});
-//# sourceMappingURL=index.cjs.map
\ No newline at end of file
+//# sourceMappingURL=index.cjs.map
diff --git a/dist/index.js b/dist/index.js
index d486894..cb8e256 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -13,6 +13,16 @@ var UNICODE_WHITESPACE_REGEX = new RegExp(UNICODE_WHITESPACE_PATTERN);
var UNICODE_WHITESPACE_REGEX_END = new RegExp(`${UNICODE_WHITESPACE_PATTERN}$`);
var UNICODE_WHITESPACE_REGEX_GLOBAL = new RegExp(UNICODE_WHITESPACE_PATTERN, "g");
+var PRE_REGISTERED_PROTOCOLS = ["tel", "callto", "sms", "cid", "xmpp"];
+var linkifyProtocolFlag = "__tolariaTiptapLinkifyProtocolsRegistered";
+var linkifyGlobalObject = typeof globalThis === "undefined" ? void 0 : globalThis;
+if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) {
+ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => {
+ registerCustomProtocol(protocol);
+ });
+ linkifyGlobalObject[linkifyProtocolFlag] = true;
+}
+
// src/helpers/autolink.ts
function isValidLinkStructure(tokens) {
if (tokens.length === 1) {
@@ -224,7 +234,8 @@ var Link = Mark.create({
});
},
onDestroy() {
- reset();
+ // Tolaria keeps a single editor shell alive across note swaps, so linkify's
+ // protocol registry must survive editor teardown and remount cycles.
},
inclusive() {
return this.options.autolink;
@@ -443,4 +454,4 @@ export {
isAllowedUri,
pasteRegex
};
-//# sourceMappingURL=index.js.map
\ No newline at end of file
+//# sourceMappingURL=index.js.map
diff --git a/src/link.ts b/src/link.ts
index 839a575..bbef783 100644
--- a/src/link.ts
+++ b/src/link.ts
@@ -8,6 +8,20 @@ import { clickHandler } from './helpers/clickHandler.js'
import { pasteHandler } from './helpers/pasteHandler.js'
import { UNICODE_WHITESPACE_REGEX_GLOBAL } from './helpers/whitespace.js'
+const PRE_REGISTERED_PROTOCOLS = ['tel', 'callto', 'sms', 'cid', 'xmpp'] as const
+const linkifyProtocolFlag = '__tolariaTiptapLinkifyProtocolsRegistered'
+const linkifyGlobalObject = typeof globalThis === 'undefined'
+ ? undefined
+ : (globalThis as Record<string, boolean | undefined>)
+
+if (linkifyGlobalObject && !linkifyGlobalObject[linkifyProtocolFlag]) {
+ PRE_REGISTERED_PROTOCOLS.forEach((protocol) => {
+ registerCustomProtocol(protocol)
+ })
+
+ linkifyGlobalObject[linkifyProtocolFlag] = true
+}
+
export interface LinkProtocolOptions {
/**
* The protocol scheme to be registered.

View File

@@ -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,

916
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,4 +7,5 @@ ignoredBuiltDependencies:
patchedDependencies:
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2': patches/@blocknote__react@0.46.2.patch
'@tiptap/extension-link@3.19.0': patches/@tiptap__extension-link@3.19.0.patch
prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch

View File

@@ -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)

View 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)
})

View File

@@ -1,47 +1,149 @@
#!/usr/bin/env node
import { mkdir, rename, rm } from 'node:fs/promises'
import { cp, mkdir, rm } from 'node:fs/promises'
import os from 'node:os'
import { resolve } from 'node:path'
import { spawn } from 'node:child_process'
const rootDir = process.cwd()
const finalCoverageDir = resolve(rootDir, 'coverage')
const coverageRunRoot = resolve(rootDir, '.tmp', 'vitest-coverage-runs')
const runId = `${Date.now()}-${process.pid}`
const runCoverageDir = resolve(coverageRunRoot, runId)
const coverageRunRoot = resolve(os.tmpdir(), 'tolaria-vitest-coverage-runs')
const forwardedArgs = process.argv.slice(2)
await mkdir(runCoverageDir, { recursive: true })
const hasFileParallelismOverride = forwardedArgs.some((arg) =>
arg === '--fileParallelism' || arg === '--no-file-parallelism'
)
const maxAttempts = 2
const packageManagerExec = process.env.npm_execpath
const command = packageManagerExec ? process.execPath : 'pnpm'
const commandArgs = packageManagerExec
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage', `--coverage.reportsDirectory=${runCoverageDir}`, ...forwardedArgs]
: ['exec', 'vitest', 'run', '--coverage', `--coverage.reportsDirectory=${runCoverageDir}`, ...forwardedArgs]
const baseCommandArgs = packageManagerExec
? [packageManagerExec, 'exec', 'vitest', 'run', '--coverage']
: ['exec', 'vitest', 'run', '--coverage']
const clearCacheCommandArgs = packageManagerExec
? [packageManagerExec, 'exec', 'vitest', '--clearCache']
: ['exec', 'vitest', '--clearCache']
const exitCode = await new Promise((resolveExit, rejectExit) => {
const child = spawn(command, commandArgs, {
cwd: rootDir,
env: process.env,
stdio: 'inherit',
})
child.on('error', rejectExit)
child.on('exit', (code, signal) => {
if (signal) {
rejectExit(new Error(`Vitest coverage exited via signal: ${signal}`))
return
}
resolveExit(code ?? 1)
})
})
if (exitCode === 0) {
await rm(finalCoverageDir, { recursive: true, force: true })
await rename(runCoverageDir, finalCoverageDir)
process.exit(0)
function isKnownVitestInternalStateFlake(output) {
return output.includes('Vitest failed to access its internal state.')
&& /Test Files\s+\d+\s+passed\s+\(\d+\)/.test(output)
&& /Tests\s+\d+\s+passed\s+\(\d+\)/.test(output)
}
console.error(`Vitest coverage artifacts preserved at ${runCoverageDir}`)
process.exit(exitCode)
function appendCapturedOutput(output, chunk) {
const nextOutput = output + chunk
return nextOutput.length > 200_000 ? nextOutput.slice(-200_000) : nextOutput
}
async function runCoverageAttempt(attempt) {
const runId = `${Date.now()}-${process.pid}-${attempt}`
const runCoverageDir = resolve(coverageRunRoot, runId)
const runCoverageTempDir = resolve(runCoverageDir, '.tmp')
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']),
`--coverage.reportsDirectory=${runCoverageDir}`,
...forwardedArgs,
]
let output = ''
const exitCode = await new Promise((resolveExit, rejectExit) => {
const child = spawn(command, commandArgs, {
cwd: rootDir,
env: {
...process.env,
VITEST_COVERAGE_DIR: runCoverageDir,
},
stdio: ['inherit', 'pipe', 'pipe'],
})
const handleOutput = (stream, target) => {
if (!stream) return
stream.setEncoding('utf8')
stream.on('data', (chunk) => {
target.write(chunk)
output = appendCapturedOutput(output, chunk)
})
}
handleOutput(child.stdout, process.stdout)
handleOutput(child.stderr, process.stderr)
child.on('error', rejectExit)
child.on('exit', (code, signal) => {
if (signal) {
rejectExit(new Error(`Vitest coverage exited via signal: ${signal}`))
return
}
resolveExit(code ?? 1)
})
})
return {
exitCode,
output,
runCoverageDir,
}
}
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) {
const run = await runCoverageAttempt(attempt)
finalRun = run
if (run.exitCode === 0) {
await rm(finalCoverageDir, { recursive: true, force: true })
await cp(run.runCoverageDir, finalCoverageDir, {
force: true,
recursive: true,
})
await rm(run.runCoverageDir, { recursive: true, force: true })
process.exit(0)
}
// Retry once when Vitest itself flakes after a fully passing suite.
if (attempt < maxAttempts && isKnownVitestInternalStateFlake(run.output)) {
console.error(`Vitest hit a known internal-state teardown flake on attempt ${attempt}; retrying once...`)
await rm(run.runCoverageDir, { recursive: true, force: true })
continue
}
break
}
console.error(`Vitest coverage artifacts preserved at ${finalRun.runCoverageDir}`)
process.exit(finalRun.exitCode)

View File

@@ -0,0 +1,111 @@
import fs 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(fs.readFileSync(filePath, 'utf8'))
}
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()
const localeFiles = fs.readdirSync(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.`)

View File

@@ -37,6 +37,6 @@ tauri-plugin-opener = "2"
tauri-plugin-prevent-default = "4.0.4"
sentry = "0.37"
uuid = { version = "1", features = ["v4"] }
tempfile = "3"
[dev-dependencies]
tempfile = "3"

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use std::io::BufRead;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -122,7 +122,11 @@ fn version_for_binary(binary: &PathBuf) -> Option<String> {
}
fn find_codex_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_codex_binary_on_path()? {
if let Some(binary) = find_codex_binary_on_path() {
return Ok(binary);
}
if let Some(binary) = find_codex_binary_in_user_shell() {
return Ok(binary);
}
@@ -133,27 +137,76 @@ fn find_codex_binary() -> Result<PathBuf, String> {
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")
fn find_codex_binary_on_path() -> Option<PathBuf> {
Command::new("which")
.arg("codex")
.output()
.map_err(|error| format!("Failed to run `which codex`: {error}"))?;
.ok()
.and_then(|output| path_from_successful_output(&output))
}
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)));
fn find_codex_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "codex"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
Ok(None)
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
Command::new(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn codex_binary_candidates() -> Vec<PathBuf> {
let home = dirs::home_dir().unwrap_or_default();
dirs::home_dir()
.map(|home| codex_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn codex_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
home.join(".asdf/shims/codex"),
home.join(".npm-global/bin/codex"),
home.join(".npm/bin/codex"),
home.join(".bun/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
@@ -244,9 +297,12 @@ fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, Strin
.to_string();
Ok(vec![
"--sandbox".into(),
"workspace-write".into(),
"--ask-for-approval".into(),
"never".into(),
"exec".into(),
"--json".into(),
"--dangerously-bypass-approvals-and-sandbox".into(),
"-C".into(),
request.vault_path.clone(),
"-c".into(),
@@ -338,6 +394,10 @@ fn format_codex_error(stderr_output: String, status: String) -> String {
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
}
if is_codex_write_permission_error(&lower) {
return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into();
}
if stderr_output.trim().is_empty() {
format!("codex exited with status {status}")
} else {
@@ -351,6 +411,17 @@ fn is_codex_auth_error(lower: &str) -> bool {
.any(|pattern| lower.contains(pattern))
}
fn is_codex_write_permission_error(lower: &str) -> bool {
[
"read-only sandbox",
"writing is blocked",
"rejected by user approval",
"rejected by the environment",
]
.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 } => {
@@ -377,8 +448,8 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
crate::claude_cli::ClaudeStreamEvent::Error { message } => {
Some(AiAgentStreamEvent::Error { message })
}
crate::claude_cli::ClaudeStreamEvent::Done
| crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
crate::claude_cli::ClaudeStreamEvent::Done => Some(AiAgentStreamEvent::Done),
crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
}
}
@@ -406,6 +477,85 @@ mod tests {
assert!(prompt.contains("User request:\nRename the note"));
}
#[test]
fn build_codex_args_uses_safe_default_permissions() {
if let Ok(args) = build_codex_args(&AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Rename the note".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
}) {
assert_eq!(args[0], "--sandbox");
assert_eq!(args[1], "workspace-write");
assert_eq!(args[2], "--ask-for-approval");
assert_eq!(args[3], "never");
assert_eq!(args[4], "exec");
assert!(!args.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
assert!(!args.contains(&"danger-full-access".to_string()));
assert!(args.contains(&"--json".to_string()));
assert!(args.contains(&"-C".to_string()));
}
}
#[test]
fn codex_binary_candidates_include_supported_macos_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = codex_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/codex"),
home.join(".codex/bin/codex"),
home.join(".local/share/mise/shims/codex"),
home.join(".asdf/shims/codex"),
home.join(".npm-global/bin/codex"),
home.join(".bun/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn first_existing_path_skips_empty_and_missing_lines() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-codex");
let codex = dir.path().join("codex");
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
let stdout = format!("\n{}\n{}\n", missing.display(), codex.display());
assert_eq!(first_existing_path(&stdout), Some(codex));
}
#[cfg(unix)]
#[test]
fn command_path_from_shell_finds_codex_from_login_shell() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let codex = dir.path().join("codex");
std::fs::write(&codex, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap();
let shell = dir.path().join("shell");
std::fs::write(
&shell,
format!(
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n",
codex.display()
),
)
.unwrap();
std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex));
}
#[test]
fn dispatch_codex_command_events_maps_to_bash_events() {
let mut events = Vec::new();
@@ -460,4 +610,23 @@ mod tests {
AiAgentStreamEvent::TextDelta { text } if text == "All set"
));
}
#[test]
fn format_codex_error_explains_vault_write_permission_failures() {
let message = format_codex_error(
"The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(),
"exit status: 1".into(),
);
assert!(message.contains("active vault"));
assert!(message.contains("writable"));
assert!(message.contains("outside"));
}
#[test]
fn map_claude_done_event_preserves_completion_signal() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done);
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
}
}

View File

@@ -1,8 +1,8 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::BufRead;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
/// Status returned by `check_claude_cli`.
#[derive(Debug, Serialize, Clone)]
@@ -63,35 +63,120 @@ pub struct AgentStreamRequest {
// ---------------------------------------------------------------------------
pub(crate) fn find_claude_binary() -> Result<PathBuf, String> {
// Try `which claude` first (works when PATH is inherited).
let output = Command::new("which")
.arg("claude")
.output()
.map_err(|e| format!("Failed to run `which claude`: {e}"))?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
if let Some(binary) = find_claude_binary_on_path() {
return Ok(binary);
}
// Fallback: check common install locations.
let home = dirs::home_dir().unwrap_or_default();
let candidates = [
home.join(".local/bin/claude"),
home.join(".npm/bin/claude"),
PathBuf::from("/usr/local/bin/claude"),
PathBuf::from("/opt/homebrew/bin/claude"),
];
for p in &candidates {
if p.exists() {
return Ok(p.clone());
}
if let Some(binary) = find_claude_binary_in_user_shell() {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(claude_binary_candidates()) {
return Ok(binary);
}
Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into())
}
fn find_claude_binary_on_path() -> Option<PathBuf> {
Command::new(claude_path_lookup_command())
.arg("claude")
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn claude_path_lookup_command() -> &'static str {
if cfg!(windows) {
"where"
} else {
"which"
}
}
fn find_claude_binary_in_user_shell() -> Option<PathBuf> {
user_shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, "claude"))
}
fn user_shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
Command::new(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
fn claude_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| claude_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn claude_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/claude"),
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude"),
home.join(".claude/local/claude.exe"),
home.join(".local/share/mise/shims/claude"),
home.join(".local/share/mise/shims/claude.exe"),
home.join(".asdf/shims/claude"),
home.join(".asdf/shims/claude.exe"),
home.join(".npm-global/bin/claude"),
home.join(".npm-global/bin/claude.cmd"),
home.join(".npm-global/bin/claude.exe"),
home.join(".npm/bin/claude"),
home.join(".npm/bin/claude.cmd"),
home.join(".npm/bin/claude.exe"),
home.join("AppData/Roaming/npm/claude.cmd"),
home.join("AppData/Roaming/npm/claude.exe"),
home.join("AppData/Local/pnpm/claude.cmd"),
home.join("AppData/Local/pnpm/claude.exe"),
home.join("scoop/shims/claude.exe"),
PathBuf::from("/opt/homebrew/bin/claude"),
PathBuf::from("/usr/local/bin/claude"),
]
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
// ---------------------------------------------------------------------------
// Public Tauri commands
// ---------------------------------------------------------------------------
@@ -184,7 +269,11 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
"--include-partial-messages".into(),
"--mcp-config".into(),
mcp_config,
"--dangerously-skip-permissions".into(),
"--strict-mcp-config".into(),
"--permission-mode".into(),
"acceptEdits".into(),
"--tools".into(),
"Read,Edit,MultiEdit,Write,Glob,Grep,LS".into(),
"--no-session-persistence".into(),
];
@@ -292,17 +381,9 @@ where
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
if !status.success() && state.session_id.is_empty() {
let msg = if stderr_output.contains("not logged in")
|| stderr_output.contains("authentication")
|| stderr_output.contains("auth")
{
"Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into()
} else if stderr_output.is_empty() {
format!("claude exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
};
emit(ClaudeStreamEvent::Error { message: msg });
emit(ClaudeStreamEvent::Error {
message: format_failed_claude_exit(&stderr_output, status),
});
}
emit(ClaudeStreamEvent::Done);
@@ -310,6 +391,25 @@ where
Ok(state.session_id)
}
fn format_failed_claude_exit(stderr_output: &str, status: ExitStatus) -> String {
if is_claude_auth_error(stderr_output) {
return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into();
}
if stderr_output.is_empty() {
format!("claude exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn is_claude_auth_error(stderr_output: &str) -> bool {
let lower = stderr_output.to_ascii_lowercase();
["not logged in", "authentication", "auth"]
.iter()
.any(|pattern| lower.contains(pattern))
}
/// Parse a single JSON line from the stream and emit the appropriate event.
fn dispatch_event<F>(json: &serde_json::Value, state: &mut StreamState, emit: &mut F)
where
@@ -970,11 +1070,16 @@ mod tests {
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"create note".to_string()));
assert!(args.contains(&"--mcp-config".to_string()));
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
assert!(args.contains(&"--strict-mcp-config".to_string()));
assert!(args.contains(&"--permission-mode".to_string()));
assert!(args.contains(&"acceptEdits".to_string()));
assert!(args.contains(&"--tools".to_string()));
assert!(args.contains(&"Read,Edit,MultiEdit,Write,Glob,Grep,LS".to_string()));
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
assert!(!args.contains(&"bypassPermissions".to_string()));
assert!(!args.contains(&"Bash".to_string()));
assert!(args.contains(&"--no-session-persistence".to_string()));
assert!(!args.contains(&"--append-system-prompt".to_string()));
// Native tools must NOT be disabled
assert!(!args.contains(&"--tools".to_string()));
}
}
@@ -1003,6 +1108,65 @@ mod tests {
// --- find_claude_binary ---
#[test]
fn claude_binary_candidates_include_supported_local_and_toolchain_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude"),
home.join(".claude/local/claude"),
home.join(".local/share/mise/shims/claude"),
home.join(".npm-global/bin/claude"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn claude_binary_candidates_include_windows_exe_installs() {
let home = PathBuf::from(r"C:\Users\alex");
let candidates = claude_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/claude.exe"),
home.join(".claude/local/claude.exe"),
home.join("AppData/Roaming/npm/claude.cmd"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
#[test]
fn claude_path_lookup_command_matches_current_platform() {
let expected = if cfg!(windows) { "where" } else { "which" };
assert_eq!(claude_path_lookup_command(), expected);
}
#[test]
fn find_existing_binary_finds_windows_exe_candidate() {
let dir = tempfile::tempdir().unwrap();
let claude = dir.path().join(".local/bin/claude.exe");
std::fs::create_dir_all(claude.parent().unwrap()).unwrap();
std::fs::write(&claude, "").unwrap();
assert_eq!(
find_existing_binary(claude_binary_candidates_for_home(dir.path())),
Some(claude)
);
}
#[test]
fn find_claude_binary_returns_result() {
let result = find_claude_binary();

View File

@@ -149,3 +149,28 @@ pub async fn stream_ai_agent(
) -> Result<String, String> {
Err("CLI AI agents are not available on mobile".into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::AiGuidanceFileState;
#[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!(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!(!restored.can_restore);
assert!(dir.path().join("AGENTS.md").exists());
assert!(dir.path().join("CLAUDE.md").exists());
}
}

View File

@@ -1,14 +1,44 @@
use crate::vault;
use super::expand_tilde;
use super::vault::VaultBoundary;
#[tauri::command]
pub async fn batch_delete_notes_async(paths: Vec<String>) -> Result<Vec<String>, String> {
let expanded: Vec<String> = paths
.iter()
.map(|path| expand_tilde(path).into_owned())
.collect();
tokio::task::spawn_blocking(move || vault::batch_delete_notes(&expanded))
pub async fn batch_delete_notes_async(
paths: Vec<String>,
vault_path: Option<String>,
) -> Result<Vec<String>, String> {
let boundary = VaultBoundary::from_request(vault_path.as_deref())?;
let validated_paths = boundary.validate_existing_paths(&paths)?;
tokio::task::spawn_blocking(move || vault::batch_delete_notes(&validated_paths))
.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());
}
}

View File

@@ -0,0 +1,50 @@
use crate::vault::{self, FolderRenameResult};
use super::expand_tilde;
#[tauri::command]
pub fn rename_vault_folder(
vault_path: String,
folder_path: String,
new_name: String,
) -> Result<FolderRenameResult, String> {
let vault_path = expand_tilde(&vault_path);
vault::rename_folder(
std::path::Path::new(vault_path.as_ref()),
&folder_path,
&new_name,
)
}
#[tauri::command]
pub fn delete_vault_folder(vault_path: String, folder_path: String) -> Result<String, String> {
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());
}
}

View File

@@ -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")
@@ -146,14 +160,14 @@ pub fn is_git_repo(vault_path: String) -> bool {
#[cfg(desktop)]
#[tauri::command]
pub fn init_git_repo(vault_path: String) -> Result<(), String> {
pub fn init_git_repo(vault_path: VaultPathArg) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
crate::git::init_repo(&vault_path)
}
#[cfg(desktop)]
#[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> {
let local_path = expand_tilde(&local_path);
crate::git::clone_repo(&url, &local_path)
}
@@ -162,28 +176,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 +208,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 +217,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 +280,112 @@ 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()
);
}
#[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));
}
}

View 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())
}

View File

@@ -1,6 +1,8 @@
mod ai;
mod delete;
mod folders;
mod git;
pub mod git_clone;
mod git_connect;
mod system;
mod vault;
@@ -10,6 +12,7 @@ use std::borrow::Cow;
pub use ai::*;
pub use delete::*;
pub use folders::*;
pub use git::*;
pub use git_connect::*;
pub use system::*;

View File

@@ -1,3 +1,6 @@
#[cfg(desktop)]
use std::process::Command;
#[cfg(desktop)]
use crate::menu;
use crate::settings::Settings;
@@ -13,6 +16,93 @@ use tauri::Window;
use super::parse_build_label;
#[cfg(desktop)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TitleBarDoubleClickAction {
Fill,
Minimize,
None,
}
#[cfg(desktop)]
fn parse_title_bar_double_click_action(value: &str) -> Option<TitleBarDoubleClickAction> {
match value.trim().to_ascii_lowercase().as_str() {
"fill" | "zoom" | "maximize" => Some(TitleBarDoubleClickAction::Fill),
"minimize" => Some(TitleBarDoubleClickAction::Minimize),
"none" | "no action" | "do nothing" => Some(TitleBarDoubleClickAction::None),
_ => None,
}
}
#[cfg(desktop)]
fn parse_legacy_title_bar_double_click_action(value: &str) -> Option<TitleBarDoubleClickAction> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" => Some(TitleBarDoubleClickAction::Minimize),
"0" | "false" | "no" => Some(TitleBarDoubleClickAction::Fill),
_ => None,
}
}
#[cfg(desktop)]
fn read_global_defaults_value(key: &str) -> Option<String> {
let output = Command::new("defaults")
.args(["read", "-g", key])
.output()
.ok()?;
parse_defaults_read_output(output)
}
#[cfg(desktop)]
fn resolve_title_bar_double_click_action(
read_value: impl Fn(&str) -> Option<String>,
) -> TitleBarDoubleClickAction {
read_value("AppleActionOnDoubleClick")
.as_deref()
.and_then(parse_title_bar_double_click_action)
.or_else(|| {
read_value("AppleMiniaturizeOnDoubleClick")
.as_deref()
.and_then(parse_legacy_title_bar_double_click_action)
})
.unwrap_or(TitleBarDoubleClickAction::Fill)
}
#[cfg(desktop)]
fn parse_defaults_read_output(output: std::process::Output) -> Option<String> {
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
}
#[cfg(desktop)]
fn apply_title_bar_double_click_action(
action: TitleBarDoubleClickAction,
is_maximized: impl FnOnce() -> Result<bool, String>,
maximize: impl FnOnce() -> Result<(), String>,
unmaximize: impl FnOnce() -> Result<(), String>,
minimize: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
match action {
TitleBarDoubleClickAction::Fill => {
if is_maximized()? {
unmaximize()
} else {
maximize()
}
}
TitleBarDoubleClickAction::Minimize => minimize(),
TitleBarDoubleClickAction::None => Ok(()),
}
}
// ── MCP commands (desktop) ──────────────────────────────────────────────────
#[cfg(desktop)]
@@ -26,8 +116,17 @@ pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
#[cfg(desktop)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
tokio::task::spawn_blocking(crate::mcp::check_mcp_status)
pub async fn remove_mcp_tools() -> Result<String, String> {
tokio::task::spawn_blocking(crate::mcp::remove_mcp)
.await
.map_err(|e| format!("Removal task failed: {e}"))
}
#[cfg(desktop)]
#[tauri::command]
pub async fn check_mcp_status(vault_path: String) -> Result<crate::mcp::McpStatus, String> {
let vault_path = super::expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::check_mcp_status(&vault_path))
.await
.map_err(|e| format!("MCP status check failed: {e}"))
}
@@ -42,7 +141,13 @@ pub async fn register_mcp_tools(_vault_path: String) -> Result<String, String> {
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
pub async fn remove_mcp_tools() -> Result<String, String> {
Err("MCP is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn check_mcp_status(_vault_path: String) -> Result<crate::mcp::McpStatus, String> {
Ok(crate::mcp::McpStatus::NotInstalled)
}
@@ -56,6 +161,8 @@ pub struct MenuStateUpdate {
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
has_no_remote: Option<bool>,
note_list_search_enabled: Option<bool>,
editor_find_enabled: Option<bool>,
}
#[cfg(desktop)]
@@ -77,6 +184,12 @@ pub fn update_menu_state(
if let Some(v) = state.has_no_remote {
menu::set_git_no_remote_items_enabled(&app_handle, v);
}
if let Some(v) = state.note_list_search_enabled {
menu::set_note_list_search_items_enabled(&app_handle, v);
}
if let Some(v) = state.editor_find_enabled {
menu::set_editor_find_items_enabled(&app_handle, v);
}
Ok(())
}
@@ -134,6 +247,20 @@ pub fn update_current_window_min_size(
.map_err(|e| e.to_string())
}
#[cfg(desktop)]
#[tauri::command]
pub fn perform_current_window_titlebar_double_click(window: Window) -> Result<(), String> {
let action = resolve_title_bar_double_click_action(read_global_defaults_value);
apply_title_bar_double_click_action(
action,
|| window.is_maximized().map_err(|e| e.to_string()),
|| window.maximize().map_err(|e| e.to_string()),
|| window.unmaximize().map_err(|e| e.to_string()),
|| window.minimize().map_err(|e| e.to_string()),
)
}
#[cfg(mobile)]
#[tauri::command]
pub fn update_current_window_min_size(
@@ -145,6 +272,12 @@ pub fn update_current_window_min_size(
Ok(())
}
#[cfg(mobile)]
#[tauri::command]
pub fn perform_current_window_titlebar_double_click(_window: tauri::Window) -> Result<(), String> {
Ok(())
}
// ── Settings & config commands ──────────────────────────────────────────────
#[tauri::command]
@@ -223,3 +356,175 @@ pub fn load_vault_list() -> Result<VaultList, String> {
pub fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(desktop)]
use std::cell::RefCell;
#[cfg(desktop)]
use std::os::unix::process::ExitStatusExt;
#[cfg(desktop)]
use std::process::{ExitStatus, Output};
#[cfg(desktop)]
use std::rc::Rc;
#[test]
fn parses_title_bar_action_values() {
for (value, expected) in [
("Fill", Some(TitleBarDoubleClickAction::Fill)),
("zoom", Some(TitleBarDoubleClickAction::Fill)),
("Minimize", Some(TitleBarDoubleClickAction::Minimize)),
("No Action", Some(TitleBarDoubleClickAction::None)),
("tile", None),
] {
assert_eq!(parse_title_bar_double_click_action(value), expected);
}
for (value, expected) in [
("1", Some(TitleBarDoubleClickAction::Minimize)),
("false", Some(TitleBarDoubleClickAction::Fill)),
("maybe", None),
] {
assert_eq!(parse_legacy_title_bar_double_click_action(value), expected);
}
}
#[test]
fn resolves_title_bar_action_preferences() {
assert_eq!(
resolve_with(&[
("AppleActionOnDoubleClick", "No Action"),
("AppleMiniaturizeOnDoubleClick", "1"),
]),
TitleBarDoubleClickAction::None
);
assert_eq!(
resolve_with(&[("AppleMiniaturizeOnDoubleClick", "1")]),
TitleBarDoubleClickAction::Minimize
);
assert_eq!(
resolve_with(&[
("AppleActionOnDoubleClick", "tile"),
("AppleMiniaturizeOnDoubleClick", "1"),
]),
TitleBarDoubleClickAction::Minimize
);
assert_eq!(resolve_with(&[]), TitleBarDoubleClickAction::Fill);
}
#[test]
fn parses_defaults_output_variants() {
for (code, stdout, expected) in [
(0, b" Maximize \n".to_vec(), Some("Maximize")),
(1, b"Minimize\n".to_vec(), None),
(0, b" \n".to_vec(), None),
(0, vec![0xff], None),
] {
assert_eq!(
parse_defaults_read_output(output(code, stdout)),
expected.map(str::to_string)
);
}
}
#[test]
fn routes_title_bar_actions_to_expected_window_calls() {
for (action, state, expected_calls) in [
(
TitleBarDoubleClickAction::Fill,
Ok(false),
vec!["is_maximized", "maximize"],
),
(
TitleBarDoubleClickAction::Fill,
Ok(true),
vec!["is_maximized", "unmaximize"],
),
(
TitleBarDoubleClickAction::Minimize,
Ok(false),
vec!["minimize"],
),
(TitleBarDoubleClickAction::None, Ok(false), Vec::new()),
] {
let (result, calls) = run_action(action, state, Ok(()), Ok(()), Ok(()));
assert_eq!(result, Ok(()));
assert_eq!(calls, expected_calls);
}
}
#[test]
fn propagates_title_bar_action_errors() {
for (state, maximize, unmaximize, expected) in [
(Err("state"), Ok(()), Ok(()), "state"),
(Ok(false), Err("maximize"), Ok(()), "maximize"),
(Ok(true), Ok(()), Err("unmaximize"), "unmaximize"),
] {
let (result, _) = run_action(
TitleBarDoubleClickAction::Fill,
state,
maximize,
unmaximize,
Ok(()),
);
assert_eq!(result, Err(expected.to_string()));
}
}
fn exit_status(code: i32) -> ExitStatus {
ExitStatus::from_raw(code << 8)
}
fn output(code: i32, stdout: Vec<u8>) -> Output {
Output {
status: exit_status(code),
stdout,
stderr: Vec::new(),
}
}
fn resolve_with(values: &[(&str, &str)]) -> TitleBarDoubleClickAction {
resolve_title_bar_double_click_action(|key| {
values
.iter()
.find(|(candidate, _)| *candidate == key)
.map(|(_, value)| (*value).to_string())
})
}
fn run_action(
action: TitleBarDoubleClickAction,
state: Result<bool, &'static str>,
maximize: Result<(), &'static str>,
unmaximize: Result<(), &'static str>,
minimize: Result<(), &'static str>,
) -> (Result<(), String>, Vec<&'static str>) {
let calls = Rc::new(RefCell::new(Vec::new()));
let state_calls = Rc::clone(&calls);
let maximize_calls = Rc::clone(&calls);
let unmaximize_calls = Rc::clone(&calls);
let minimize_calls = Rc::clone(&calls);
let result = apply_title_bar_double_click_action(
action,
move || {
state_calls.borrow_mut().push("is_maximized");
state.map_err(str::to_string)
},
move || {
maximize_calls.borrow_mut().push("maximize");
maximize.map_err(str::to_string)
},
move || {
unmaximize_calls.borrow_mut().push("unmaximize");
unmaximize.map_err(str::to_string)
},
move || {
minimize_calls.borrow_mut().push("minimize");
minimize.map_err(str::to_string)
},
);
let call_log = calls.borrow().clone();
(result, call_log)
}
}

View File

@@ -1,321 +1,77 @@
use crate::frontmatter::FrontmatterValue;
use crate::search::SearchResponse;
use crate::vault::{
DetectedRename, FolderNode, RenameResult, VaultEntry, ViewDefinition, ViewFile,
};
use crate::{frontmatter, git, search, vault};
mod boundary;
mod file_cmds;
mod frontmatter_cmds;
mod lifecycle_cmds;
mod rename_cmds;
mod scan_cmds;
mod view_cmds;
use super::expand_tilde;
// ── Vault commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path);
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn list_vault_folders(path: String) -> Result<Vec<FolderNode>, String> {
let path = expand_tilde(&path);
vault::scan_vault_folders(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn get_note_content(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::get_note_content(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_note_content(path: String, content: String) -> Result<(), String> {
let path = expand_tilde(&path);
vault::save_note_content(&path, &content)
}
#[tauri::command]
pub fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
old_title: Option<String>,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref())
}
#[tauri::command]
pub fn rename_note_filename(
vault_path: String,
old_path: String,
new_filename_stem: String,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note_filename(&vault_path, &old_path, &new_filename_stem)
}
#[tauri::command]
pub fn auto_rename_untitled(
vault_path: String,
note_path: String,
) -> Result<Option<RenameResult>, String> {
let vault_path = expand_tilde(&vault_path);
let note_path = expand_tilde(&note_path);
vault::auto_rename_untitled(&vault_path, &note_path)
}
#[tauri::command]
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&vault_path);
vault::detect_renames(&vault_path)
}
#[tauri::command]
pub fn update_wikilinks_for_renames(
vault_path: String,
renames: Vec<DetectedRename>,
) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::update_wikilinks_for_renames(&vault_path, &renames)
}
#[tauri::command]
pub fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
let expanded: Vec<String> = paths.iter().map(|p| expand_tilde(p).into_owned()).collect();
vault::batch_delete_notes(&expanded)
}
#[tauri::command]
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let folder_path =
build_vault_folder_path(std::path::Path::new(vault_path.as_ref()), &folder_name);
ensure_missing_folder(&folder_path, &folder_name)?;
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name)
}
fn build_vault_folder_path(vault_root: &std::path::Path, folder_name: &str) -> std::path::PathBuf {
vault_root.join(folder_name)
}
fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Result<(), String> {
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
Ok(())
}
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
git::init_repo(vault_path)?;
vault::seed_config_files(vault_path);
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &std::path::Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string()
}
#[tauri::command]
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
let path = expand_tilde(&target_path).into_owned();
let vault_dir = std::path::Path::new(&path);
initialize_empty_vault(vault_dir, &path)?;
Ok(canonical_vault_path_string(vault_dir))
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = resolve_getting_started_target(target_path.as_deref())?;
vault::create_getting_started_vault(&path)
}
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
match target_path {
Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()),
_ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()),
}
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
pub async fn reload_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path).into_owned();
tokio::task::spawn_blocking(move || {
vault::invalidate_cache(std::path::Path::new(&path));
vault::scan_vault_cached(std::path::Path::new(&path))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub fn reload_vault_entry(path: String) -> Result<VaultEntry, String> {
let path = expand_tilde(&path);
vault::reload_entry(std::path::Path::new(path.as_ref()))
}
/// Sync the `title` frontmatter field with the filename on note open.
/// Returns `true` if the file was modified (title was absent or desynced).
#[tauri::command]
pub fn sync_note_title(path: String) -> Result<bool, String> {
use vault::SyncAction;
let path = expand_tilde(&path);
let action = vault::sync_title_on_open(std::path::Path::new(path.as_ref()))?;
Ok(matches!(action, SyncAction::Updated { .. }))
}
#[tauri::command]
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::copy_image_to_vault(&vault_path, &source_path)
}
// ── View commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_views(vault_path: String) -> Vec<ViewFile> {
let path = expand_tilde(&vault_path);
vault::scan_views(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_view_cmd(
vault_path: String,
filename: String,
definition: ViewDefinition,
) -> Result<(), String> {
let path = expand_tilde(&vault_path);
vault::save_view(std::path::Path::new(path.as_ref()), &filename, &definition)
}
#[tauri::command]
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
let path = expand_tilde(&vault_path);
vault::delete_view(std::path::Path::new(path.as_ref()), &filename)
}
// ── Frontmatter commands ────────────────────────────────────────────────────
#[tauri::command]
pub fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "_archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
}
// ── Search commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
// ── Repair command ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(&vault_path)?;
Ok("Vault repaired".to_string())
}
pub(super) use boundary::VaultBoundary;
pub use file_cmds::*;
pub use frontmatter_cmds::*;
pub use lifecycle_cmds::*;
pub use rename_cmds::*;
pub use scan_cmds::*;
pub use view_cmds::*;
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::ViewDefinition;
use std::path::Path;
const ACTIVE_VAULT_PATH_ERROR: &str = super::boundary::ACTIVE_VAULT_PATH_ERROR;
const INVALID_VIEW_FILENAME_ERROR: &str = super::boundary::INVALID_VIEW_FILENAME_ERROR;
fn vault_path_arg(vault_path: &Path) -> Option<std::path::PathBuf> {
Some(vault_path.to_path_buf())
}
fn vault_path_string_arg(vault_path: &Path) -> Option<String> {
Some(vault_path.to_string_lossy().to_string())
}
fn assert_note_write_rejects_escape<T: std::fmt::Debug>(
action: impl FnOnce(std::path::PathBuf, String, Option<std::path::PathBuf>) -> Result<T, String>,
) {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let escape_path = vault_path.join("../outside.md");
let err = action(
escape_path,
"# Outside\n".to_string(),
vault_path_arg(vault_path),
)
.expect_err("expected traversal write to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
fn sample_view_definition() -> ViewDefinition {
ViewDefinition {
name: "Inbox".to_string(),
icon: None,
color: None,
sort: None,
list_properties_display: vec![],
filters: crate::vault::FilterGroup::All(vec![]),
}
}
fn assert_save_view_cmd_rejects_invalid_filename(filename: &str) {
let dir = tempfile::TempDir::new().unwrap();
let err = save_view_cmd(
dir.path().to_string_lossy().to_string(),
filename.to_string(),
sample_view_definition(),
)
.expect_err("expected invalid filename to be rejected");
assert_eq!(err, INVALID_VIEW_FILENAME_ERROR);
}
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("note.md");
@@ -356,9 +112,13 @@ mod tests {
#[test]
fn test_batch_archive_notes() {
let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
let (dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
assert_eq!(
batch_archive_notes(vec![note.to_str().unwrap().to_string()]).unwrap(),
batch_archive_notes(
vec![note.to_str().unwrap().to_string()],
vault_path_string_arg(dir.path()),
)
.unwrap(),
1
);
let content = std::fs::read_to_string(&note).unwrap();
@@ -372,22 +132,78 @@ mod tests {
let note = dir.path().join("test.md");
std::fs::write(&note, "---\ntitle: Test\nStatus: Active\n---\n# Test\n").unwrap();
let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
let entry = reload_vault_entry(note.clone(), vault_path_arg(dir.path())).unwrap();
assert_eq!(entry.title, "Test");
assert_eq!(entry.status, Some("Active".to_string()));
// Modify file on disk
std::fs::write(&note, "---\ntitle: Test\nStatus: Done\n---\n# Test\n").unwrap();
let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap();
let fresh = reload_vault_entry(note, vault_path_arg(dir.path())).unwrap();
assert_eq!(fresh.status, Some("Done".to_string()));
}
#[test]
fn test_reload_vault_entry_nonexistent() {
let result = reload_vault_entry("/nonexistent/path.md".to_string());
let result = reload_vault_entry("/nonexistent/path.md".into(), None);
assert!(result.is_err());
}
#[test]
fn test_get_note_content_rejects_path_outside_active_vault() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path();
let inside = vault_path.join("inside.md");
let outside_dir = tempfile::TempDir::new().unwrap();
let outside = outside_dir.path().join("outside.md");
std::fs::write(&inside, "# Inside\n").unwrap();
std::fs::write(&outside, "# Outside\n").unwrap();
let err = get_note_content(outside, vault_path_arg(vault_path))
.expect_err("expected out-of-vault read to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_save_note_content_rejects_traversal_outside_active_vault() {
assert_note_write_rejects_escape(save_note_content);
}
#[test]
fn test_create_note_content_rejects_traversal_outside_active_vault() {
assert_note_write_rejects_escape(create_note_content);
}
#[test]
fn test_create_vault_folder_rejects_escape_path() {
let dir = tempfile::TempDir::new().unwrap();
let err = create_vault_folder(dir.path().into(), "../escape".into())
.expect_err("expected escaping folder path to be rejected");
assert_eq!(err, ACTIVE_VAULT_PATH_ERROR);
}
#[test]
fn test_create_vault_folder_rejects_windows_invalid_names() {
let dir = tempfile::TempDir::new().unwrap();
let err = create_vault_folder(dir.path().into(), "con".into())
.expect_err("expected Windows-invalid folder name to be rejected");
assert_eq!(err, "Invalid folder name");
}
#[test]
fn test_save_view_cmd_rejects_nested_filename() {
assert_save_view_cmd_rejects_invalid_filename("../escape.yml");
}
#[test]
fn test_save_view_cmd_rejects_windows_invalid_filename() {
assert_save_view_cmd_rejects_invalid_filename("con.yml");
}
#[test]
fn test_reload_vault_invalidates_cache_and_rescans() {
let dir = tempfile::TempDir::new().unwrap();
@@ -430,7 +246,7 @@ mod tests {
.output()
.unwrap();
let entries = list_vault(vault_path.to_str().unwrap().to_string()).unwrap();
let entries = list_vault(vault_path.into()).unwrap();
assert!(!entries[0].archived);
std::fs::write(

View File

@@ -0,0 +1,287 @@
use crate::commands::expand_tilde;
use crate::vault::filename_rules::validate_view_filename_stem;
use crate::vault_list;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
pub(crate) const ACTIVE_VAULT_PATH_ERROR: &str = "Path must stay inside the active vault";
const ACTIVE_VAULT_MISMATCH_ERROR: &str = "Vault path must match the active vault";
const ACTIVE_VAULT_UNAVAILABLE_ERROR: &str = "Active vault is not available";
const NO_ACTIVE_VAULT_ERROR: &str = "No active vault selected";
pub(crate) const INVALID_VIEW_FILENAME_ERROR: &str = "Invalid view filename";
#[derive(Clone, Debug)]
struct VaultRootPaths {
requested: PathBuf,
canonical: PathBuf,
}
#[derive(Clone, Debug)]
pub(crate) struct VaultBoundary {
requested_root: PathBuf,
canonical_root: PathBuf,
}
impl VaultBoundary {
pub(crate) fn from_request(requested_vault_path: Option<&str>) -> Result<Self, String> {
let configured_root = if cfg!(test) {
None
} else {
load_configured_active_vault_root()?
};
let requested_root = requested_vault_path
.filter(|path| !path.trim().is_empty())
.map(build_vault_root_paths)
.transpose()?;
let root = match (configured_root, requested_root) {
(Some(configured), Some(requested)) => {
if configured.canonical != requested.canonical {
return Err(ACTIVE_VAULT_MISMATCH_ERROR.to_string());
}
requested
}
(Some(configured), None) => configured,
(None, Some(requested)) => requested,
(None, None) => return Err(NO_ACTIVE_VAULT_ERROR.to_string()),
};
Ok(Self {
requested_root: root.requested,
canonical_root: root.canonical,
})
}
pub(crate) fn requested_root(&self) -> &Path {
&self.requested_root
}
fn requested_root_str(&self) -> String {
path_to_string(&self.requested_root)
}
fn validate_existing_path(&self, raw_path: &str) -> Result<String, String> {
self.validate_path(raw_path, false)
}
pub(crate) fn validate_existing_paths(
&self,
raw_paths: &[String],
) -> Result<Vec<String>, String> {
raw_paths
.iter()
.map(|path| self.validate_existing_path(path))
.collect()
}
fn validate_writable_path(&self, raw_path: &str) -> Result<String, String> {
self.validate_path(raw_path, true)
}
pub(crate) fn child_path(&self, relative_path: &str) -> Result<PathBuf, String> {
validate_relative_child_path(relative_path)?;
let requested = self.requested_root.join(relative_path);
let canonical = canonicalize_candidate_for_write(&requested)?;
self.ensure_within_root(&canonical)?;
Ok(requested)
}
fn validate_path(&self, raw_path: &str, allow_missing_leaf: bool) -> Result<String, String> {
let requested = self.requested_path(raw_path);
let canonical = if allow_missing_leaf {
canonicalize_candidate_for_write(&requested)?
} else {
requested
.canonicalize()
.map_err(|_| "File does not exist".to_string())?
};
self.ensure_within_root(&canonical)?;
Ok(path_to_string(&requested))
}
fn requested_path(&self, raw_path: &str) -> PathBuf {
let expanded = PathBuf::from(expand_tilde(raw_path).into_owned());
if expanded.is_absolute() {
expanded
} else {
self.requested_root.join(expanded)
}
}
fn ensure_within_root(&self, candidate: &Path) -> Result<(), String> {
candidate
.strip_prefix(&self.canonical_root)
.map(|_| ())
.map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string())
}
}
fn load_configured_active_vault_root() -> Result<Option<VaultRootPaths>, String> {
let list = vault_list::load_vault_list()?;
list.active_vault
.as_deref()
.filter(|path| !path.trim().is_empty())
.map(build_vault_root_paths)
.transpose()
}
fn build_vault_root_paths(raw_vault_path: &str) -> Result<VaultRootPaths, String> {
let requested = PathBuf::from(expand_tilde(raw_vault_path).into_owned());
let canonical = requested
.canonicalize()
.map_err(|_| ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string())?;
if !canonical.is_dir() {
return Err(ACTIVE_VAULT_UNAVAILABLE_ERROR.to_string());
}
Ok(VaultRootPaths {
requested,
canonical,
})
}
fn canonicalize_candidate_for_write(path: &Path) -> Result<PathBuf, String> {
let (ancestor, tail) = find_existing_ancestor(path)?;
Ok(tail
.into_iter()
.fold(ancestor, |current, segment| current.join(segment)))
}
fn find_existing_ancestor(path: &Path) -> Result<(PathBuf, Vec<OsString>), String> {
let mut current = path;
let mut tail = Vec::new();
loop {
if current.exists() {
let canonical = current
.canonicalize()
.map_err(|_| ACTIVE_VAULT_PATH_ERROR.to_string())?;
tail.reverse();
return Ok((canonical, tail));
}
let file_name = current
.file_name()
.ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?;
tail.push(file_name.to_os_string());
current = current
.parent()
.ok_or_else(|| ACTIVE_VAULT_PATH_ERROR.to_string())?;
}
}
fn validate_relative_child_path(relative_path: &str) -> Result<(), String> {
if relative_path.trim().is_empty() {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
let path = Path::new(relative_path);
if path.is_absolute() {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
if path.components().any(|component| {
matches!(
component,
Component::CurDir | Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(ACTIVE_VAULT_PATH_ERROR.to_string());
}
Ok(())
}
pub(crate) fn validate_view_filename(filename: &str) -> Result<(), String> {
if !filename.ends_with(".yml") {
return Err("Filename must end with .yml".to_string());
}
let path = Path::new(filename);
let mut components = path.components();
match (components.next(), components.next()) {
(Some(Component::Normal(value)), None) => {
let stem = value.to_string_lossy();
let stem = stem.strip_suffix(".yml").unwrap_or(&stem);
validate_view_filename_stem(stem)
}
_ => Err(INVALID_VIEW_FILENAME_ERROR.to_string()),
}
}
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
pub(crate) fn with_boundary<T>(
requested_vault_path: Option<&str>,
action: impl FnOnce(&VaultBoundary) -> Result<T, String>,
) -> Result<T, String> {
let boundary = VaultBoundary::from_request(requested_vault_path)?;
action(&boundary)
}
pub(crate) enum ValidatedPathMode {
Existing,
Writable,
}
pub(crate) fn with_validated_path<T>(
path: &str,
vault_path: Option<&str>,
mode: ValidatedPathMode,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(vault_path, |boundary| {
let validated_path = match mode {
ValidatedPathMode::Existing => boundary.validate_existing_path(path)?,
ValidatedPathMode::Writable => boundary.validate_writable_path(path)?,
};
action(&validated_path)
})
}
pub(crate) fn with_existing_paths<T>(
paths: &[String],
vault_path: Option<&str>,
action: impl FnOnce(Vec<String>) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(vault_path, |boundary| {
let validated_paths = boundary.validate_existing_paths(paths)?;
action(validated_paths)
})
}
pub(crate) fn with_requested_root<T>(
vault_path: &str,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
let requested_root = boundary.requested_root_str();
action(&requested_root)
})
}
pub(crate) fn with_existing_path_in_requested_vault<T>(
vault_path: &str,
path: &str,
action: impl FnOnce(&str, &str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
let requested_root = boundary.requested_root_str();
let validated_path = boundary.validate_existing_path(path)?;
action(&requested_root, &validated_path)
})
}
pub(crate) fn with_view_file<T>(
vault_path: &str,
filename: &str,
action: impl FnOnce(&str, &str) -> Result<T, String>,
) -> Result<T, String> {
with_boundary(Some(vault_path), |boundary| {
validate_view_filename(filename)?;
let requested_root = boundary.requested_root_str();
action(&requested_root, filename)
})
}

View File

@@ -0,0 +1,326 @@
use crate::commands::expand_tilde;
use crate::vault::filename_rules::validate_folder_name;
use crate::vault::{self, FolderNode, VaultEntry};
use std::path::{Path, PathBuf};
use super::boundary::{
with_boundary, with_existing_paths, with_requested_root, with_validated_path, ValidatedPathMode,
};
fn with_note_path<T>(
path: &Path,
vault_path: Option<&Path>,
mode: ValidatedPathMode,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
let raw_path = path.to_string_lossy();
let raw_vault_path = vault_path.map(|value| value.to_string_lossy());
with_validated_path(
&raw_path,
raw_vault_path.as_deref(),
mode,
|validated_path| action(Path::new(validated_path)),
)
}
fn with_expanded_vault_root<T>(
path: &Path,
action: impl FnOnce(&Path) -> Result<T, String>,
) -> Result<T, String> {
let raw_path = path.to_string_lossy();
let expanded = expand_tilde(raw_path.as_ref()).into_owned();
action(Path::new(&expanded))
}
fn with_requested_root_path<T>(
vault_path: &Path,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
let raw_vault_path = vault_path.to_string_lossy();
with_requested_root(raw_vault_path.as_ref(), action)
}
fn sync_image_asset_scope(
app_handle: &tauri::AppHandle,
requested_root: &str,
) -> Result<(), String> {
#[cfg(desktop)]
crate::sync_vault_asset_scope(app_handle, Path::new(requested_root))?;
#[cfg(not(desktop))]
let _ = requested_root;
#[cfg(not(desktop))]
let _ = app_handle;
Ok(())
}
fn with_image_asset_scope(
app_handle: &tauri::AppHandle,
vault_path: &Path,
action: impl FnOnce(&str) -> Result<String, String>,
) -> Result<String, String> {
with_requested_root_path(vault_path, |requested_root| {
let saved_path = action(requested_root)?;
sync_image_asset_scope(app_handle, requested_root)?;
Ok(saved_path)
})
}
#[tauri::command]
pub fn sync_vault_asset_scope_for_window(
app_handle: tauri::AppHandle,
vault_path: PathBuf,
) -> Result<(), String> {
with_requested_root_path(vault_path.as_path(), |requested_root| {
sync_image_asset_scope(&app_handle, requested_root)
})
}
fn with_writable_note_path<T>(
path: PathBuf,
vault_path: Option<PathBuf>,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
with_validated_path(
path.to_string_lossy().as_ref(),
vault_path
.as_ref()
.map(|value| value.to_string_lossy())
.as_deref(),
ValidatedPathMode::Writable,
action,
)
}
#[tauri::command]
pub fn get_note_content(path: PathBuf, vault_path: Option<PathBuf>) -> Result<String, String> {
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
vault::get_note_content,
)
}
#[tauri::command]
pub fn save_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_writable_note_path(path, vault_path, |validated_path| {
vault::save_note_content(validated_path, &content)
})
}
#[tauri::command]
pub fn create_note_content(
path: PathBuf,
content: String,
vault_path: Option<PathBuf>,
) -> Result<(), String> {
with_writable_note_path(path, vault_path, |validated_path| {
vault::create_note_content(validated_path, &content)
})
}
#[tauri::command]
pub fn delete_note(path: PathBuf) -> Result<String, String> {
with_validated_path(
path.to_string_lossy().as_ref(),
None,
ValidatedPathMode::Existing,
vault::delete_note,
)
}
#[tauri::command]
pub fn batch_delete_notes(paths: Vec<PathBuf>) -> Result<Vec<String>, String> {
let raw_paths = paths
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
with_existing_paths(&raw_paths, None, |validated_paths| {
vault::batch_delete_notes(&validated_paths)
})
}
#[tauri::command]
pub fn create_vault_folder(vault_path: PathBuf, folder_name: PathBuf) -> Result<String, String> {
let raw_vault_path = vault_path.to_string_lossy();
with_boundary(Some(raw_vault_path.as_ref()), |boundary| {
let folder_name = folder_name.to_string_lossy();
let folder_path = boundary.child_path(folder_name.as_ref())?;
validate_folder_name(folder_name.as_ref())?;
ensure_missing_folder(&folder_path, folder_name.as_ref())?;
std::fs::create_dir_all(&folder_path)
.map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name.into_owned())
})
}
fn ensure_missing_folder(folder_path: &Path, folder_name: &str) -> Result<(), String> {
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
Ok(())
}
/// Sync the `title` frontmatter field with the filename on note open.
/// Returns `true` if the file was modified (title was absent or desynced).
#[tauri::command]
pub fn sync_note_title(path: PathBuf, vault_path: Option<PathBuf>) -> Result<bool, String> {
use vault::SyncAction;
with_note_path(
path.as_path(),
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| {
let action = vault::sync_title_on_open(validated_path)?;
Ok(matches!(action, SyncAction::Updated { .. }))
},
)
}
#[tauri::command]
pub fn save_image(
app_handle: tauri::AppHandle,
vault_path: PathBuf,
filename: String,
data: String,
) -> Result<String, String> {
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
vault::save_image(requested_root, &filename, &data)
})
}
#[tauri::command]
pub fn copy_image_to_vault(
app_handle: tauri::AppHandle,
vault_path: PathBuf,
source_path: PathBuf,
) -> Result<String, String> {
with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| {
vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref())
})
}
#[tauri::command]
pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_cached)
}
#[tauri::command]
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), vault::scan_vault_folders)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn vault_root(dir: &TempDir) -> PathBuf {
dir.path().to_path_buf()
}
fn note_path(dir: &TempDir, name: &str) -> PathBuf {
dir.path().join(name)
}
#[test]
fn note_content_commands_roundtrip_with_requested_vault() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = note_path(&dir, "notes/command-note.md");
create_note_content(
note.clone(),
"# Command Note\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert_eq!(
get_note_content(note.clone(), Some(root.clone())).unwrap(),
"# Command Note\n"
);
save_note_content(
note.clone(),
"---\ntitle: Command Note\n---\n# Command Note\nBody\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert!(!sync_note_title(note.clone(), Some(root.clone())).unwrap());
save_note_content(
note.clone(),
"# Updated Command Note\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert!(sync_note_title(note.clone(), Some(root.clone())).unwrap());
assert!(get_note_content(note, Some(root))
.unwrap()
.contains("title: Command Note"));
}
#[test]
fn note_content_commands_accept_windows_sensitive_valid_segments() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
let note = root
.join("@raflymln")
.join("notes with spaces")
.join("résumé note.md");
save_note_content(
note.clone(),
"# Windows-Sensitive Path\n\nBody\n".to_string(),
Some(root.clone()),
)
.unwrap();
assert_eq!(
get_note_content(note, Some(root)).unwrap(),
"# Windows-Sensitive Path\n\nBody\n"
);
}
#[test]
fn folder_and_listing_commands_use_expanded_vault_root() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
assert_eq!(
create_vault_folder(root.clone(), PathBuf::from("Projects")).unwrap(),
"Projects"
);
fs::write(dir.path().join("Projects/project.md"), "# Project\n").unwrap();
let entries = list_vault(root.clone()).unwrap();
assert!(entries.iter().any(|entry| entry.filename == "root.md"));
assert!(entries.iter().any(|entry| entry.filename == "project.md"));
let folders = list_vault_folders(root).unwrap();
assert!(folders.iter().any(|folder| folder.name == "Projects"));
}
#[test]
fn commands_reject_paths_outside_requested_vault() {
let vault = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_note = outside.path().join("outside.md");
fs::write(&outside_note, "# Outside\n").unwrap();
let error = get_note_content(outside_note, Some(vault.path().to_path_buf())).unwrap_err();
assert!(error.contains("Path must stay inside the active vault"));
let folder_error =
create_vault_folder(vault.path().to_path_buf(), PathBuf::from("../escape"))
.unwrap_err();
assert!(folder_error.contains("Path must stay inside the active vault"));
}
}

View File

@@ -0,0 +1,48 @@
use crate::frontmatter;
use crate::frontmatter::FrontmatterValue;
use super::boundary::{with_existing_paths, with_validated_path, ValidatedPathMode};
#[tauri::command]
pub fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
vault_path: Option<String>,
) -> Result<String, String> {
with_validated_path(
&path,
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| frontmatter::update_frontmatter(validated_path, &key, value),
)
}
#[tauri::command]
pub fn delete_frontmatter_property(
path: String,
key: String,
vault_path: Option<String>,
) -> Result<String, String> {
with_validated_path(
&path,
vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| frontmatter::delete_frontmatter_property(validated_path, &key),
)
}
#[tauri::command]
pub fn batch_archive_notes(
paths: Vec<String>,
vault_path: Option<String>,
) -> Result<usize, String> {
with_existing_paths(&paths, vault_path.as_deref(), |validated_paths| {
let mut count = 0;
for path in &validated_paths {
frontmatter::update_frontmatter(path, "_archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
})
}

View File

@@ -0,0 +1,92 @@
use crate::commands::expand_tilde;
use crate::{git, vault};
use std::path::Path;
#[tauri::command]
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
let path = expand_tilde(&target_path).into_owned();
let vault_dir = Path::new(&path);
initialize_empty_vault(vault_dir, &path)?;
Ok(canonical_vault_path_string(vault_dir))
}
fn initialize_empty_vault(vault_dir: &Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
git::init_repo(vault_path)?;
vault::seed_config_files(vault_path);
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &Path) -> String {
vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string()
}
#[tauri::command]
pub async fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = resolve_getting_started_target(target_path.as_deref())?;
tokio::task::spawn_blocking(move || vault::create_getting_started_vault(&path))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
match target_path {
Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()),
_ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()),
}
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|path| path.to_string_lossy().to_string())
}
#[tauri::command]
pub fn repair_vault(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)?;
vault::repair_config_files(&vault_path)?;
git::ensure_gitignore(&vault_path)?;
Ok("Vault repaired".to_string())
}

View File

@@ -0,0 +1,230 @@
use crate::commands::expand_tilde;
use crate::vault::{self, DetectedRename, RenameResult};
use std::path::Path;
use super::boundary::{
with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode,
};
#[tauri::command]
pub fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
old_title: Option<String>,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(
&vault_path,
&old_path,
|requested_root, validated_path| {
vault::rename_note(vault::RenameNoteRequest {
vault_path: requested_root,
old_path: validated_path,
new_title: &new_title,
old_title_hint: old_title.as_deref(),
})
},
)
}
#[tauri::command]
pub fn rename_note_filename(
vault_path: String,
old_path: String,
new_filename_stem: String,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(
&vault_path,
&old_path,
|requested_root, validated_path| {
vault::rename_note_filename(vault::RenameNoteFilenameRequest {
vault_path: requested_root,
old_path: validated_path,
new_filename_stem: &new_filename_stem,
})
},
)
}
#[tauri::command]
pub fn move_note_to_folder(
vault_path: String,
old_path: String,
folder_path: String,
) -> Result<RenameResult, String> {
with_existing_path_in_requested_vault(
&vault_path,
&old_path,
|requested_root, validated_path| {
let trimmed_folder_path = folder_path.trim();
if trimmed_folder_path.is_empty() {
return Err("Folder path cannot be empty".to_string());
}
let folder_absolute_path = Path::new(requested_root).join(trimmed_folder_path);
with_validated_path(
folder_absolute_path.to_string_lossy().as_ref(),
Some(&vault_path),
ValidatedPathMode::Existing,
|validated_folder_path| {
let validated_folder = Path::new(validated_folder_path);
if !validated_folder.is_dir() {
return Err(format!("Folder does not exist: {}", trimmed_folder_path));
}
vault::move_note_to_folder(vault::MoveNoteToFolderRequest {
vault_path: requested_root,
old_path: validated_path,
destination_folder_path: validated_folder_path,
})
},
)
},
)
}
#[tauri::command]
pub fn auto_rename_untitled(
vault_path: String,
note_path: String,
) -> Result<Option<RenameResult>, String> {
with_existing_path_in_requested_vault(
&vault_path,
&note_path,
|requested_root, validated_path| {
vault::auto_rename_untitled(vault::AutoRenameUntitledRequest {
vault_path: requested_root,
note_path: validated_path,
})
},
)
}
#[tauri::command]
pub fn detect_renames(vault_path: String) -> Result<Vec<DetectedRename>, String> {
let vault_path = expand_tilde(&vault_path);
vault::detect_renames(Path::new(vault_path.as_ref()))
}
#[tauri::command]
pub fn update_wikilinks_for_renames(
vault_path: String,
renames: Vec<DetectedRename>,
) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::update_wikilinks_for_renames(Path::new(vault_path.as_ref()), &renames)
}
#[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 write_note(dir: &TempDir, relative_path: &str, content: &str) -> String {
let path = dir.path().join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn rename_note_command_updates_title_file_and_links() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let old_path = write_note(
&dir,
"old-title.md",
"---\ntitle: Old Title\n---\n# Old Title\n",
);
let linked_path = write_note(&dir, "linked.md", "See [[Old Title]].\n");
let result = rename_note(
vault.clone(),
old_path.clone(),
"New Title".to_string(),
None,
)
.unwrap();
assert!(result.new_path.ends_with("new-title.md"));
assert!(!Path::new(&old_path).exists());
assert!(Path::new(&result.new_path).exists());
assert!(fs::read_to_string(linked_path)
.unwrap()
.contains("[[new-title]]"));
assert_eq!(result.failed_updates, 0);
}
#[test]
fn filename_and_folder_commands_preserve_note_content() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let old_path = write_note(
&dir,
"draft.md",
"---\ntitle: Draft Title\n---\n# Draft Title\n",
);
let renamed =
rename_note_filename(vault.clone(), old_path, "custom-name".to_string()).unwrap();
assert!(renamed.new_path.ends_with("custom-name.md"));
fs::create_dir(dir.path().join("Projects")).unwrap();
let moved = move_note_to_folder(
vault.clone(),
renamed.new_path.clone(),
"Projects".to_string(),
)
.unwrap();
assert!(moved.new_path.ends_with("Projects/custom-name.md"));
assert!(fs::read_to_string(moved.new_path)
.unwrap()
.contains("Draft Title"));
}
#[test]
fn auto_rename_and_detected_rename_commands_route_through_vault() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let untitled = write_note(&dir, "untitled-note-123.md", "# Project Plan\n");
let auto = auto_rename_untitled(vault.clone(), untitled)
.unwrap()
.unwrap();
assert!(auto.new_path.ends_with("project-plan.md"));
crate::git::init_repo(&vault).unwrap();
let old_path = dir.path().join("project-plan.md");
let new_path = dir.path().join("plans.md");
fs::rename(&old_path, &new_path).unwrap();
crate::hidden_command("git")
.args(["add", "-A"])
.current_dir(dir.path())
.output()
.unwrap();
let renames = detect_renames(vault.clone()).unwrap();
assert_eq!(renames.len(), 1);
assert_eq!(renames[0].old_path, "project-plan.md");
assert_eq!(renames[0].new_path, "plans.md");
assert_eq!(update_wikilinks_for_renames(vault, renames).unwrap(), 0);
}
#[test]
fn move_note_to_folder_rejects_empty_folder() {
let dir = TempDir::new().unwrap();
let vault = vault_path(&dir);
let note = write_note(&dir, "note.md", "# Note\n");
let error = move_note_to_folder(vault, note, " ".to_string()).unwrap_err();
assert!(error.contains("Folder path cannot be empty"));
}
}

View File

@@ -0,0 +1,170 @@
use crate::commands::expand_tilde;
use crate::search::SearchResponse;
use crate::vault::VaultEntry;
use crate::{search, vault, vault_list};
use std::path::{Path, PathBuf};
use super::boundary::{with_validated_path, ValidatedPathMode};
fn collect_registered_vault_roots(vault_list: &vault_list::VaultList) -> Vec<PathBuf> {
let mut roots = vault_list
.vaults
.iter()
.map(|entry| PathBuf::from(expand_tilde(&entry.path).into_owned()))
.collect::<Vec<_>>();
if let Some(active_vault) = &vault_list.active_vault {
roots.push(PathBuf::from(expand_tilde(active_vault).into_owned()));
}
roots
}
fn find_registered_vault_root(path: &Path, registered_roots: &[PathBuf]) -> Option<PathBuf> {
registered_roots
.iter()
.filter_map(|root| {
let canonical_root = root.canonicalize().ok()?;
path.starts_with(&canonical_root)
.then_some((canonical_root.components().count(), root.clone()))
})
.max_by_key(|(depth, _)| *depth)
.map(|(_, root)| root)
}
fn resolve_reload_vault_path(
path: &Path,
vault_path: Option<&Path>,
) -> Result<Option<PathBuf>, String> {
if let Some(vault_path) = vault_path {
return Ok(Some(vault_path.to_path_buf()));
}
if !path.is_absolute() {
return Ok(None);
}
let canonical_path = match path.canonicalize() {
Ok(canonical_path) => canonical_path,
Err(_) => return Ok(None),
};
let vault_list = vault_list::load_vault_list()?;
let registered_roots = collect_registered_vault_roots(&vault_list);
Ok(find_registered_vault_root(
canonical_path.as_path(),
&registered_roots,
))
}
#[tauri::command]
pub fn reload_vault_entry(
path: PathBuf,
vault_path: Option<PathBuf>,
) -> Result<VaultEntry, String> {
let resolved_vault_path = resolve_reload_vault_path(path.as_path(), vault_path.as_deref())?;
let raw_path = path.to_string_lossy();
let raw_vault_path = resolved_vault_path
.as_ref()
.map(|value| value.to_string_lossy().into_owned());
with_validated_path(
&raw_path,
raw_vault_path.as_deref(),
ValidatedPathMode::Existing,
|validated_path| vault::reload_entry(Path::new(validated_path)),
)
}
#[tauri::command]
pub async fn reload_vault(
app_handle: tauri::AppHandle,
path: String,
) -> Result<Vec<crate::vault::VaultEntry>, String> {
let path = expand_tilde(&path).into_owned();
crate::sync_vault_asset_scope(&app_handle, Path::new(&path))?;
tokio::task::spawn_blocking(move || {
vault::invalidate_cache(Path::new(&path));
vault::scan_vault_cached(Path::new(&path))
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[tauri::command]
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
#[cfg(test)]
mod tests {
use super::{collect_registered_vault_roots, find_registered_vault_root};
use crate::vault_list::{VaultEntry as VaultListEntry, VaultList};
#[test]
fn finds_registered_vault_root_for_an_absolute_note_path() {
let dir = tempfile::TempDir::new().unwrap();
let vault_root = dir.path().join("vault");
let note_path = vault_root.join("note.md");
std::fs::create_dir_all(&vault_root).unwrap();
std::fs::write(&note_path, "# Note\n").unwrap();
let vault_list = VaultList {
vaults: vec![VaultListEntry {
label: "Test".to_string(),
path: vault_root.to_string_lossy().into_owned(),
}],
active_vault: None,
hidden_defaults: vec![],
};
let registered_roots = collect_registered_vault_roots(&vault_list);
let canonical_note_path = note_path.canonicalize().unwrap();
assert_eq!(
find_registered_vault_root(canonical_note_path.as_path(), &registered_roots),
Some(vault_root),
);
}
#[test]
fn prefers_the_deepest_registered_vault_root() {
let dir = tempfile::TempDir::new().unwrap();
let parent_root = dir.path().join("vault");
let nested_root = parent_root.join("projects");
let note_path = nested_root.join("note.md");
std::fs::create_dir_all(&nested_root).unwrap();
std::fs::write(&note_path, "# Note\n").unwrap();
let vault_list = VaultList {
vaults: vec![
VaultListEntry {
label: "Parent".to_string(),
path: parent_root.to_string_lossy().into_owned(),
},
VaultListEntry {
label: "Nested".to_string(),
path: nested_root.to_string_lossy().into_owned(),
},
],
active_vault: None,
hidden_defaults: vec![],
};
let registered_roots = collect_registered_vault_roots(&vault_list);
let canonical_note_path = note_path.canonicalize().unwrap();
assert_eq!(
find_registered_vault_root(canonical_note_path.as_path(), &registered_roots),
Some(nested_root),
);
}
}

View File

@@ -0,0 +1,37 @@
use crate::vault::{self, ViewDefinition, ViewFile};
use std::path::Path;
use super::boundary::{with_boundary, with_view_file};
#[tauri::command]
pub fn list_views(vault_path: String) -> Result<Vec<ViewFile>, String> {
with_boundary(Some(vault_path.as_str()), |boundary| {
Ok(vault::scan_views(boundary.requested_root()))
})
}
#[tauri::command]
pub fn save_view_cmd(
vault_path: String,
filename: String,
definition: ViewDefinition,
) -> Result<(), String> {
with_view_file(
&vault_path,
&filename,
|requested_root, validated_filename| {
vault::save_view(Path::new(requested_root), validated_filename, &definition)
},
)
}
#[tauri::command]
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
with_view_file(
&vault_path,
&filename,
|requested_root, validated_filename| {
vault::delete_view(Path::new(requested_root), validated_filename)
},
)
}

View File

@@ -1,12 +1,20 @@
use std::path::Path;
use std::process::Command;
use std::process::{Command, Output, Stdio};
use super::git_command;
struct CloneRequest<'a> {
url: &'a str,
dest: &'a Path,
}
/// Clone a git repository to a local path using the system git configuration.
pub fn clone_repo(url: &str, local_path: &str) -> Result<String, String> {
let dest = Path::new(local_path);
let request = CloneRequest { url, dest };
prepare_clone_destination(dest)?;
if let Err(err) = run_clone(url, dest) {
if let Err(err) = run_clone(&request) {
cleanup_failed_clone(dest);
return Err(err);
}
@@ -64,12 +72,14 @@ fn directory_has_entries(dest: &Path) -> Result<bool, String> {
.map(|mut entries| entries.next().is_some())
}
fn run_clone(url: &str, dest: &Path) -> Result<(), String> {
let destination = dest
.to_str()
.ok_or_else(|| format!("Destination '{}' is not valid UTF-8", dest.display()))?;
let output = Command::new("git")
.args(["clone", "--progress", url, destination])
fn run_clone(request: &CloneRequest<'_>) -> Result<(), String> {
let destination = request.dest.to_str().ok_or_else(|| {
format!(
"Destination '{}' is not valid UTF-8",
request.dest.display()
)
})?;
let output = build_clone_command(request, destination)
.output()
.map_err(|e| format!("Failed to run git clone: {}", e))?;
@@ -77,8 +87,36 @@ fn run_clone(url: &str, dest: &Path) -> Result<(), String> {
return Ok(());
}
Err(format!(
"git clone failed: {}",
clone_failure_message(&output)
))
}
fn build_clone_command(request: &CloneRequest<'_>, destination: &str) -> Command {
let mut command = git_command();
command
.args(["clone", "--quiet", request.url, destination])
.env("GIT_TERMINAL_PROMPT", "0")
.env("SSH_ASKPASS_REQUIRE", "never")
.stdin(Stdio::null());
command
}
fn clone_failure_message(output: &Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("git clone failed: {}", stderr.trim()))
let stderr = stderr.trim();
if !stderr.is_empty() {
return stderr.to_string();
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.trim();
if !stdout.is_empty() {
return stdout.to_string();
}
format!("git clone exited with status {}", output.status)
}
fn cleanup_failed_clone(dest: &Path) {
@@ -91,6 +129,7 @@ fn cleanup_failed_clone(dest: &Path) {
mod tests {
use super::*;
use std::fs;
use std::os::unix::process::ExitStatusExt;
use std::path::Path;
use std::process::Command as StdCommand;
@@ -160,4 +199,56 @@ mod tests {
);
assert!(result.unwrap_err().contains("git clone failed"));
}
#[test]
fn test_clone_failure_message_falls_back_to_stdout() {
let output = Output {
status: std::process::ExitStatus::from_raw(128),
stdout: b"fatal: stdout only".to_vec(),
stderr: Vec::new(),
};
assert_eq!(clone_failure_message(&output), "fatal: stdout only");
}
#[test]
fn test_build_clone_command_disables_interactive_prompts() {
let dest = Path::new("/tmp/repo");
let request = CloneRequest {
url: "https://example.com/repo.git",
dest,
};
let command = build_clone_command(&request, "/tmp/repo");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
let envs = command
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().to_string(),
value.map(|entry| entry.to_string_lossy().to_string()),
)
})
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(
envs.get("GIT_TERMINAL_PROMPT"),
Some(&Some("0".to_string()))
);
assert_eq!(
envs.get("SSH_ASKPASS_REQUIRE"),
Some(&Some("never".to_string()))
);
assert_eq!(
args,
vec![
"clone".to_string(),
"--quiet".to_string(),
"https://example.com/repo.git".to_string(),
"/tmp/repo".to_string(),
]
);
}
}

View File

@@ -1,12 +1,17 @@
use super::git_command;
use std::path::Path;
use std::process::Command;
struct CommitFailure {
stdout: String,
stderr: String,
}
/// Commit all changes with a message.
pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Stage all changes
let add = Command::new("git")
let add = git_command()
.args(["add", "-A"])
.current_dir(vault)
.output()
@@ -17,34 +22,73 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
return Err(format!("git add failed: {}", stderr));
}
// Commit
let commit = Command::new("git")
match run_commit(vault, message, false) {
Ok(stdout) => Ok(stdout),
Err(failure) if is_commit_signing_failure(&failure.detail()) => {
run_commit(vault, message, true).map_err(|retry_failure| {
format!(
"git commit signing failed; retried without signing but git commit still failed: {}",
retry_failure.detail()
)
})
}
Err(failure) => Err(format!("git commit failed: {}", failure.detail())),
}
}
fn run_commit(vault: &Path, message: &str, disable_signing: bool) -> Result<String, CommitFailure> {
let mut command = git_command();
if disable_signing {
command.args(["-c", "commit.gpgsign=false"]);
}
let commit = command
.args(["commit", "-m", message])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?;
.map_err(|e| CommitFailure {
stdout: String::new(),
stderr: format!("Failed to run git commit: {}", e),
})?;
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
let stdout = String::from_utf8_lossy(&commit.stdout);
// git writes "nothing to commit" to stdout, not stderr
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
return Err(format!("git commit failed: {}", detail.trim()));
if commit.status.success() {
return Ok(String::from_utf8_lossy(&commit.stdout).to_string());
}
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
Err(CommitFailure {
stdout: String::from_utf8_lossy(&commit.stdout).to_string(),
stderr: String::from_utf8_lossy(&commit.stderr).to_string(),
})
}
impl CommitFailure {
fn detail(&self) -> String {
// git writes "nothing to commit" to stdout, not stderr.
let detail = if self.stderr.trim().is_empty() {
&self.stdout
} else {
&self.stderr
};
detail.trim().to_string()
}
}
fn is_commit_signing_failure(detail: &str) -> bool {
let lower = detail.to_ascii_lowercase();
lower.contains("cannot run gpg")
|| lower.contains("gpg failed to sign")
|| lower.contains("failed to sign the data")
|| lower.contains("gpg.ssh")
|| (lower.contains("failed to write commit object")
&& (lower.contains("sign") || lower.contains("gpg")))
}
#[cfg(test)]
mod tests {
use super::git_command;
use super::*;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_git_commit() {
@@ -57,7 +101,7 @@ mod tests {
assert!(result.is_ok());
// Verify the commit exists
let log = Command::new("git")
let log = git_command()
.args(["log", "--oneline", "-1"])
.current_dir(vault)
.output()
@@ -84,4 +128,53 @@ mod tests {
"Error should mention 'nothing to commit'"
);
}
#[test]
fn test_git_commit_retries_without_signing_when_gpg_is_missing() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
git_command()
.args(["config", "commit.gpgsign", "true"])
.current_dir(vault)
.output()
.unwrap();
git_command()
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
.current_dir(vault)
.output()
.unwrap();
fs::write(vault.join("signed-config.md"), "# Signed config\n").unwrap();
let result = git_commit(vp, "Commit with broken signing config");
assert!(
result.is_ok(),
"commit should retry unsigned when signing helper is missing: {result:?}"
);
let log = git_command()
.args(["log", "--oneline", "-1"])
.current_dir(vault)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&log.stdout).contains("Commit with broken signing config"));
let config = git_command()
.args(["config", "commit.gpgsign"])
.current_dir(vault)
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&config.stdout).trim(), "true");
}
#[test]
fn test_commit_signing_failure_detection_is_specific() {
assert!(is_commit_signing_failure(
"error: cannot run gpg: No such file or directory\nfatal: failed to write commit object"
));
assert!(!is_commit_signing_failure(
"On branch main\nnothing to commit, working tree clean"
));
}
}

View File

@@ -1,7 +1,6 @@
use std::path::Path;
use std::process::Command;
use super::run_git;
use super::{git_command, run_git};
/// List files with merge conflicts (unmerged paths).
///
@@ -10,7 +9,7 @@ use super::run_git;
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["ls-files", "--unmerged"])
.current_dir(vault)
.output()
@@ -93,13 +92,13 @@ pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String
let mode = get_conflict_mode(vault_path);
let output = match mode.as_str() {
"rebase" => Command::new("git")
"rebase" => git_command()
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
_ => Command::new("git")
_ => git_command()
.args(["commit", "-m", "Resolve merge conflicts"])
.current_dir(vault)
.output()
@@ -131,7 +130,6 @@ mod tests {
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use crate::git::{git_commit, git_pull, git_push};
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[test]
@@ -203,35 +201,30 @@ mod tests {
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_resolve_conflict_ours() {
fn assert_resolve_conflict_strategy(strategy: &str, expected_content: &str) {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(conflicts.contains(&"conflict.md".to_string()));
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
git_resolve_conflict(vp_b, "conflict.md", strategy).unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version B\n");
assert_eq!(content, expected_content);
}
#[test]
fn test_resolve_conflict_ours() {
assert_resolve_conflict_strategy("ours", "# Version B\n");
}
#[test]
fn test_resolve_conflict_theirs() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version A\n");
assert_resolve_conflict_strategy("theirs", "# Version A\n");
}
#[test]
@@ -244,7 +237,7 @@ mod tests {
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
let log = Command::new("git")
let log = git_command()
.args(["log", "--oneline", "-1"])
.current_dir(clone_b.path())
.output()
@@ -310,7 +303,7 @@ mod tests {
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
let output = Command::new("git")
let output = git_command()
.args(["pull", "--rebase"])
.current_dir(clone_b_dir.path())
.output()

View File

@@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::{Command, Output};
use std::process::Output;
use super::{ensure_author_config, git_command};
const DEFAULT_REMOTE_NAME: &str = "origin";
@@ -90,6 +92,8 @@ pub fn git_add_remote(vault_path: &str, remote_url: &str) -> Result<GitAddRemote
));
}
ensure_author_config(vault)?;
let branch = current_branch(vault)?;
if branch.is_empty() {
return Ok(connect_result(
@@ -192,14 +196,14 @@ fn list_remotes(vault: &Path) -> Result<Vec<String>, String> {
}
fn unset_upstream(vault: &Path) {
let _ = Command::new("git")
let _ = git_command()
.args(["branch", "--unset-upstream"])
.current_dir(vault)
.output();
}
fn run_git(vault: &Path, args: &[&str]) -> Result<(), String> {
let output = Command::new("git")
let output = git_command()
.args(args)
.current_dir(vault)
.output()
@@ -237,7 +241,7 @@ fn list_remote_branches(vault: &Path) -> Result<Vec<String>, String> {
}
fn histories_share_base(vault: &Path, connection: &RemoteConnection) -> bool {
Command::new("git")
git_command()
.args(["merge-base", "HEAD", connection.remote_branch.as_str()])
.current_dir(vault)
.output()
@@ -315,7 +319,7 @@ fn classify_connect_error(stderr: &str) -> GitAddRemoteResult {
}
fn git_output(vault: &Path, args: &[&str]) -> Result<Output, String> {
Command::new("git")
git_command()
.args(args)
.current_dir(vault)
.output()
@@ -442,6 +446,28 @@ mod tests {
git_commit(path.to_str().unwrap(), message).unwrap();
}
fn clear_local_author(path: &Path) {
for key in ["user.name", "user.email"] {
StdCommand::new("git")
.args(["config", "--local", "--unset-all", key])
.current_dir(path)
.output()
.unwrap();
}
}
fn local_author_is_configured(path: &Path) -> bool {
["user.name", "user.email"].into_iter().all(|key| {
let output = StdCommand::new("git")
.args(["config", "--local", key])
.current_dir(path)
.output()
.unwrap();
output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty()
})
}
#[test]
fn disconnect_all_remotes_removes_every_remote() {
let dir = setup_git_repo();
@@ -487,6 +513,26 @@ mod tests {
assert_eq!((status.ahead, status.behind), (0, 0));
}
#[test]
fn git_add_remote_sets_local_identity_when_existing_repo_has_none() {
let local = setup_git_repo();
create_local_commit(local.path(), "note.md", "Local", "Initial local commit");
clear_local_author(local.path());
assert!(!local_author_is_configured(local.path()));
let bare = TempDir::new().unwrap();
init_bare_remote(bare.path());
let result = git_add_remote(
local.path().to_str().unwrap(),
bare.path().to_str().unwrap(),
)
.unwrap();
assert_eq!(result.status, "connected");
assert!(local_author_is_configured(local.path()));
}
#[test]
fn git_add_remote_pushes_when_remote_is_the_local_branch_ancestor() {
let local = setup_git_repo();

View File

@@ -1,7 +1,7 @@
use super::git_command;
use chrono::DateTime;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
/// Git-derived creation and modification timestamps for a file.
#[derive(Debug, Clone)]
@@ -19,7 +19,7 @@ pub struct GitDates {
/// Files not yet committed (untracked / only staged) will not appear in the map;
/// callers should fall back to filesystem metadata for those.
pub fn get_all_file_dates(vault_path: &Path) -> HashMap<String, GitDates> {
let output = match Command::new("git")
let output = match git_command()
.args(["log", "--format=COMMIT %aI", "--name-only"])
.current_dir(vault_path)
.output()

View File

@@ -1,5 +1,5 @@
use super::git_command;
use std::path::Path;
use std::process::Command;
use super::GitCommit;
@@ -16,7 +16,7 @@ pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitComm
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let output = Command::new("git")
let output = git_command()
.args([
"log",
"--format=%H|%h|%an|%aI|%s",
@@ -80,7 +80,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
// First try tracked file diff
let output = Command::new("git")
let output = git_command()
.args(["diff", "--", relative_str])
.current_dir(vault)
.output()
@@ -90,7 +90,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
// If no diff (maybe staged or untracked), try diff --cached
if stdout.is_empty() {
let cached = Command::new("git")
let cached = git_command()
.args(["diff", "--cached", "--", relative_str])
.current_dir(vault)
.output()
@@ -102,7 +102,7 @@ pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String
}
// Try showing untracked file as all-new
let status = Command::new("git")
let status = git_command()
.args(["status", "--porcelain", "--", relative_str])
.current_dir(vault)
.output()
@@ -144,7 +144,7 @@ pub fn get_file_diff_at_commit(
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
// Show diff between commit^ and commit for this file
let output = Command::new("git")
let output = git_command()
.args([
"diff",
&format!("{}^", commit_hash),
@@ -161,7 +161,7 @@ pub fn get_file_diff_at_commit(
// If diff is empty, it might be the initial commit (no parent).
// Fall back to showing the full file content as added.
if stdout.is_empty() {
let show = Command::new("git")
let show = git_command()
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
.current_dir(vault)
.output()
@@ -184,10 +184,10 @@ pub fn get_file_diff_at_commit(
#[cfg(test)]
mod tests {
use super::git_command;
use super::*;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_get_file_history_with_commits() {
@@ -196,24 +196,24 @@ mod tests {
let file = vault.join("test.md");
fs::write(&file, "# Initial\n").unwrap();
Command::new("git")
git_command()
.args(["add", "test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Initial commit"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# Updated\n\nNew content.").unwrap();
Command::new("git")
git_command()
.args(["add", "test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Update test"])
.current_dir(vault)
.output()
@@ -248,12 +248,12 @@ mod tests {
let file = vault.join("diff-test.md");
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
Command::new("git")
git_command()
.args(["add", "diff-test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Add diff-test"])
.current_dir(vault)
.output()
@@ -275,31 +275,31 @@ mod tests {
let file = vault.join("diff-at-commit.md");
fs::write(&file, "# First\n\nOriginal content.").unwrap();
Command::new("git")
git_command()
.args(["add", "diff-at-commit.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "First commit"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# First\n\nModified content.").unwrap();
Command::new("git")
git_command()
.args(["add", "diff-at-commit.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Second commit"])
.current_dir(vault)
.output()
.unwrap();
// Get hash of second commit
let log = Command::new("git")
let log = git_command()
.args(["log", "--format=%H", "-1"])
.current_dir(vault)
.output()
@@ -321,18 +321,18 @@ mod tests {
let file = vault.join("initial.md");
fs::write(&file, "# Initial\n\nHello world.").unwrap();
Command::new("git")
git_command()
.args(["add", "initial.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Initial commit"])
.current_dir(vault)
.output()
.unwrap();
let log = Command::new("git")
let log = git_command()
.args(["log", "--format=%H", "-1"])
.current_dir(vault)
.output()

View File

@@ -56,6 +56,10 @@ const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never co
*.swp\n\
*.swo\n";
fn git_command() -> Command {
crate::hidden_command("git")
}
/// Ensure a `.gitignore` with sensible defaults exists in the vault directory.
/// Creates the file if missing; leaves existing `.gitignore` files untouched.
pub fn ensure_gitignore(path: &str) -> Result<(), String> {
@@ -79,18 +83,31 @@ pub fn init_repo(path: &str) -> Result<(), String> {
ensure_gitignore(path)?;
run_git(dir, &["add", "."])?;
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
commit_initial_vault_setup(dir)?;
Ok(())
}
fn commit_initial_vault_setup(dir: &Path) -> Result<(), String> {
run_git(
dir,
&[
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"Initial vault setup",
],
)
}
/// Run a git command in the given directory, returning an error on failure.
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
let output = Command::new("git")
let output = git_command()
.args(args)
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to run git {}: {}", args[0], e))?;
.map_err(|e| format!("Failed to run git {}: {e}", git_command_label(args)))?;
if output.status.success() {
return Ok(());
@@ -98,27 +115,37 @@ fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
Err(format!(
"git {} failed: {}",
args[0],
git_command_label(args),
String::from_utf8_lossy(&output.stderr)
))
}
fn git_command_label<'a>(args: &'a [&'a str]) -> &'a str {
if args.first() == Some(&"-c") {
return args.get(2).copied().unwrap_or(args[0]);
}
args[0]
}
/// Set local user.name and user.email if not already configured.
fn ensure_author_config(dir: &Path) -> Result<(), String> {
pub(crate) fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [
("user.name", "Tolaria"),
("user.email", "vault@tolaria.app"),
] {
let check = Command::new("git")
.args(["config", key])
let local = git_command()
.args(["config", "--local", key])
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to check git config: {}", e))?;
.map_err(|e| format!("Failed to check git config {key}: {e}"))?;
let value = String::from_utf8_lossy(&check.stdout);
if !check.status.success() || value.trim().is_empty() {
run_git(dir, &["config", key, fallback])?;
let value = String::from_utf8_lossy(&local.stdout);
if local.status.success() && !value.trim().is_empty() {
continue;
}
run_git(dir, &["config", "--local", key, fallback])?;
}
Ok(())
}
@@ -153,7 +180,6 @@ fn parse_github_repo_path(url: &str) -> Option<String> {
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn assert_repo_path(url: &str, expected: Option<&str>) {
@@ -167,19 +193,19 @@ mod tests {
let dir = TempDir::new().unwrap();
let path = dir.path();
Command::new("git")
git_command()
.args(["init", "--initial-branch=main"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["config", "user.name", "Test User"])
.current_dir(path)
.output()
@@ -193,14 +219,14 @@ mod tests {
let bare_dir = TempDir::new().unwrap();
let bare = bare_dir.path();
Command::new("git")
git_command()
.args(["init", "--bare"])
.current_dir(bare)
.output()
.unwrap();
let clone_a_dir = TempDir::new().unwrap();
Command::new("git")
git_command()
.args(["clone", bare.to_str().unwrap(), "."])
.current_dir(clone_a_dir.path())
.output()
@@ -209,7 +235,7 @@ mod tests {
&["config", "user.email", "a@test.com"][..],
&["config", "user.name", "User A"][..],
] {
Command::new("git")
git_command()
.args(*cmd)
.current_dir(clone_a_dir.path())
.output()
@@ -217,7 +243,7 @@ mod tests {
}
let clone_b_dir = TempDir::new().unwrap();
Command::new("git")
git_command()
.args(["clone", bare.to_str().unwrap(), "."])
.current_dir(clone_b_dir.path())
.output()
@@ -226,7 +252,7 @@ mod tests {
&["config", "user.email", "b@test.com"][..],
&["config", "user.name", "User B"][..],
] {
Command::new("git")
git_command()
.args(*cmd)
.current_dir(clone_b_dir.path())
.output()
@@ -280,7 +306,7 @@ mod tests {
init_repo(vault.to_str().unwrap()).unwrap();
let log = Command::new("git")
let log = git_command()
.args(["log", "--oneline"])
.current_dir(&vault)
.output()
@@ -289,6 +315,39 @@ mod tests {
assert!(log_str.contains("Initial vault setup"));
}
#[test]
fn test_init_repo_creates_initial_commit_when_signing_is_misconfigured() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
git_command()
.args(["init"])
.current_dir(&vault)
.output()
.unwrap();
git_command()
.args(["config", "commit.gpgsign", "true"])
.current_dir(&vault)
.output()
.unwrap();
git_command()
.args(["config", "gpg.program", "/missing/tolaria-test-gpg"])
.current_dir(&vault)
.output()
.unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let log = git_command()
.args(["log", "--oneline"])
.current_dir(&vault)
.output()
.unwrap();
assert!(String::from_utf8_lossy(&log.stdout).contains("Initial vault setup"));
}
#[test]
fn test_init_repo_stages_all_files() {
let dir = TempDir::new().unwrap();
@@ -299,7 +358,7 @@ mod tests {
init_repo(vault.to_str().unwrap()).unwrap();
let status = Command::new("git")
let status = git_command()
.args(["status", "--porcelain"])
.current_dir(&vault)
.output()

View File

@@ -1,8 +1,7 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use super::parse_github_repo_path;
use super::{git_command, parse_github_repo_path};
#[derive(Debug, Serialize, Clone)]
pub struct PulseFile {
@@ -67,7 +66,7 @@ pub fn get_vault_pulse(
let limit_str = limit.to_string();
let skip_str = skip.to_string();
let output = Command::new("git")
let output = git_command()
.args([
"log",
"--name-status",
@@ -99,7 +98,7 @@ pub fn get_vault_pulse(
fn get_github_base_url(vault_path: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
@@ -123,69 +122,90 @@ fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCo
continue;
}
if line.contains('|')
&& !line.starts_with(|c: char| {
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
})
{
// Commit header line: hash|short_hash|message|date
if let Some(commit) = current.take() {
commits.push(commit);
}
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
let hash = parts[0];
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
let github_url = github_base
.as_ref()
.map(|base| format!("{}/commit/{}", base, hash));
if is_commit_header(line) {
push_current_commit(&mut commits, &mut current);
current = parse_commit_header(line, github_base);
continue;
}
current = Some(PulseCommit {
hash: hash.to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
date,
github_url,
files: Vec::new(),
added: 0,
modified: 0,
deleted: 0,
});
}
} else if let Some(ref mut commit) = current {
// File status line: A\tpath or M\tpath
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
if file_parts.len() == 2 {
let status = parse_file_status(file_parts[0].trim());
let path = file_parts[1].trim();
match status {
"added" => commit.added += 1,
"deleted" => commit.deleted += 1,
_ => commit.modified += 1,
}
commit.files.push(PulseFile {
path: path.to_string(),
status: status.to_string(),
title: title_from_path(path),
});
}
if let Some(ref mut commit) = current {
add_file_change(commit, line);
}
}
if let Some(commit) = current {
commits.push(commit);
}
push_current_commit(&mut commits, &mut current);
commits
}
fn is_git_status_line(line: &str) -> bool {
line.starts_with(|c: char| {
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
})
}
fn is_commit_header(line: &str) -> bool {
line.contains('|') && !is_git_status_line(line)
}
fn push_current_commit(commits: &mut Vec<PulseCommit>, current: &mut Option<PulseCommit>) {
if let Some(commit) = current.take() {
commits.push(commit);
}
}
fn parse_commit_header(line: &str, github_base: &Option<String>) -> Option<PulseCommit> {
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() != 4 {
return None;
}
let hash = parts[0];
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
let github_url = github_base
.as_ref()
.map(|base| format!("{}/commit/{}", base, hash));
Some(PulseCommit {
hash: hash.to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
date,
github_url,
files: Vec::new(),
added: 0,
modified: 0,
deleted: 0,
})
}
fn add_file_change(commit: &mut PulseCommit, line: &str) {
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
if file_parts.len() != 2 {
return;
}
let status = parse_file_status(file_parts[0].trim());
let path = file_parts[1].trim();
match status {
"added" => commit.added += 1,
"deleted" => commit.deleted += 1,
_ => commit.modified += 1,
}
commit.files.push(PulseFile {
path: path.to_string(),
status: status.to_string(),
title: title_from_path(path),
});
}
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["log", "-1", "--format=%H|%h"])
.current_dir(vault)
.output()
@@ -223,23 +243,7 @@ pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>,
/// Try to build a GitHub commit URL from the origin remote URL.
fn get_github_commit_url(vault_path: &str, full_hash: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = parse_github_repo_path(&url)?;
Some(format!(
"https://github.com/{}/commit/{}",
repo_path, full_hash
))
get_github_base_url(vault_path).map(|base| format!("{}/commit/{}", base, full_hash))
}
#[cfg(test)]

View File

@@ -1,6 +1,6 @@
use super::git_command;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use super::conflict::get_conflict_files;
@@ -17,7 +17,7 @@ pub struct GitPullResult {
/// Check whether the vault repo has at least one remote configured.
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["remote"])
.current_dir(vault)
.output()
@@ -40,7 +40,7 @@ pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
});
}
let output = Command::new("git")
let output = git_command()
.args(["pull", "--no-rebase"])
.current_dir(vault)
.output()
@@ -134,14 +134,14 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
}
// Fetch latest remote refs (silent, best-effort)
let _ = Command::new("git")
let _ = git_command()
.args(["fetch", "--quiet"])
.current_dir(vault)
.output();
let branch = current_branch(vault)?;
let output = Command::new("git")
let output = git_command()
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
.current_dir(vault)
.output()
@@ -171,7 +171,7 @@ pub fn git_remote_status(vault_path: &str) -> Result<GitRemoteStatus, String> {
}
fn current_branch(vault: &Path) -> Result<String, String> {
let output = Command::new("git")
let output = git_command()
.args(["branch", "--show-current"])
.current_dir(vault)
.output()
@@ -189,59 +189,93 @@ pub struct GitPushResult {
pub fn classify_push_error(stderr: &str) -> GitPushResult {
let lower = stderr.to_lowercase();
if lower.contains("non-fast-forward")
|| lower.contains("[rejected]")
|| lower.contains("fetch first")
|| lower.contains("failed to push some refs")
&& (lower.contains("updates were rejected") || lower.contains("non-fast-forward"))
{
return GitPushResult {
status: "rejected".to_string(),
message: "Push rejected: remote has new commits. Pull first, then push.".to_string(),
};
if is_rejected_push_error(&lower) {
return push_error(
"rejected",
"Push rejected: remote has new commits. Pull first, then push.",
);
}
if lower.contains("authentication failed")
|| lower.contains("could not read username")
|| lower.contains("permission denied")
|| lower.contains("403")
|| lower.contains("invalid credentials")
{
return GitPushResult {
status: "auth_error".to_string(),
message: "Push failed: authentication error. Check your credentials.".to_string(),
};
if is_auth_push_error(&lower) {
return push_error(
"auth_error",
"Push failed: authentication error. Check your credentials.",
);
}
if lower.contains("could not resolve host")
|| lower.contains("unable to access")
|| lower.contains("connection refused")
|| lower.contains("network is unreachable")
|| lower.contains("timed out")
{
return GitPushResult {
status: "network_error".to_string(),
message: "Push failed: network error. Check your connection and try again.".to_string(),
};
if is_network_push_error(&lower) {
return push_error(
"network_error",
"Push failed: network error. Check your connection and try again.",
);
}
// Fallback: extract the hint line if present, otherwise use the full stderr
push_error(
"error",
format!("Push failed: {}", push_error_detail(stderr)),
)
}
fn push_error(status: &str, message: impl Into<String>) -> GitPushResult {
GitPushResult {
status: status.to_string(),
message: message.into(),
}
}
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
fn is_rejected_push_error(lower: &str) -> bool {
contains_any(lower, &["non-fast-forward", "[rejected]", "fetch first"])
|| (lower.contains("failed to push some refs")
&& contains_any(lower, &["updates were rejected", "non-fast-forward"]))
}
fn is_auth_push_error(lower: &str) -> bool {
contains_any(
lower,
&[
"authentication failed",
"could not read username",
"permission denied",
"403",
"invalid credentials",
],
)
}
fn is_network_push_error(lower: &str) -> bool {
contains_any(
lower,
&[
"could not resolve host",
"unable to access",
"connection refused",
"network is unreachable",
"timed out",
],
)
}
fn push_error_detail(stderr: &str) -> String {
let hint_line = stderr
.lines()
.find(|l| l.trim_start().starts_with("hint:"))
.map(|l| l.trim_start().strip_prefix("hint:").unwrap_or(l).trim())
.find(|line| line.trim_start().starts_with("hint:"))
.map(|line| {
line.trim_start()
.strip_prefix("hint:")
.unwrap_or(line)
.trim()
})
.unwrap_or("")
.to_string();
let detail = if hint_line.is_empty() {
if hint_line.is_empty() {
stderr.trim().to_string()
} else {
hint_line
};
GitPushResult {
status: "error".to_string(),
message: format!("Push failed: {detail}"),
}
}
@@ -249,7 +283,7 @@ pub fn classify_push_error(stderr: &str) -> GitPushResult {
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["push"])
.current_dir(vault)
.output()
@@ -272,7 +306,6 @@ mod tests {
use crate::git::git_commit;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use std::fs;
use std::process::Command;
#[test]
fn test_has_remote_returns_false_for_local_repo() {
@@ -289,7 +322,7 @@ mod tests {
let vault = dir.path();
let vp = vault.to_str().unwrap();
Command::new("git")
git_command()
.args(["remote", "add", "origin", "https://example.com/repo.git"])
.current_dir(vault)
.output()

View File

@@ -1,7 +1,7 @@
use super::git_command;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Serialize, Clone)]
pub struct ModifiedFile {
@@ -23,14 +23,34 @@ struct DiffStats {
binary: bool,
}
fn status_label(status_code: &str) -> &'static str {
match status_code.trim() {
"M" | "MM" | "AM" => "modified",
"A" => "added",
"D" => "deleted",
"??" => "untracked",
"R" | "RM" => "renamed",
_ => "modified",
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileChangeStatus {
Modified,
Added,
Deleted,
Untracked,
Renamed,
}
impl FileChangeStatus {
fn from_code(status_code: &str) -> Self {
match status_code.trim() {
"A" => Self::Added,
"D" => Self::Deleted,
"??" => Self::Untracked,
"R" | "RM" => Self::Renamed,
_ => Self::Modified,
}
}
fn label(self) -> &'static str {
match self {
Self::Added => "added",
Self::Deleted => "deleted",
Self::Untracked => "untracked",
Self::Renamed => "renamed",
Self::Modified => "modified",
}
}
}
@@ -68,7 +88,7 @@ fn parse_numstat_line(line: &str) -> Option<(String, DiffStats)> {
}
fn repo_has_head(vault: &Path) -> Result<bool, String> {
let output = Command::new("git")
let output = git_command()
.args(["rev-parse", "--verify", "HEAD"])
.current_dir(vault)
.output()
@@ -82,7 +102,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
return Ok(HashMap::new());
}
let output = Command::new("git")
let output = git_command()
.args(["diff", "--numstat", "--find-renames", "HEAD", "--"])
.current_dir(vault)
.output()
@@ -100,7 +120,7 @@ fn load_diff_stats(vault: &Path) -> Result<HashMap<String, DiffStats>, String> {
.collect::<HashMap<_, _>>())
}
fn count_worktree_lines(vault: &Path, relative_path: &str) -> DiffStats {
fn count_worktree_lines(vault: &Path, relative_path: &Path) -> DiffStats {
let full_path = vault.join(relative_path);
let added_lines = std::fs::read_to_string(full_path)
.ok()
@@ -115,19 +135,20 @@ fn count_worktree_lines(vault: &Path, relative_path: &str) -> DiffStats {
fn resolve_diff_stats(
vault: &Path,
relative_path: &str,
status: &str,
relative_path: &Path,
status: FileChangeStatus,
diff_stats: &HashMap<String, DiffStats>,
) -> DiffStats {
if status == "untracked" {
if status == FileChangeStatus::Untracked {
return count_worktree_lines(vault, relative_path);
}
diff_stats.get(relative_path).copied().unwrap_or_default()
let key = relative_path.to_string_lossy();
diff_stats.get(key.as_ref()).copied().unwrap_or_default()
}
fn ensure_path_within_vault(vault: &Path, relative_path: &str, abs: &Path) -> Result<(), String> {
for component in Path::new(relative_path).components() {
fn ensure_path_within_vault(vault: &Path, relative_path: &Path, abs: &Path) -> Result<(), String> {
for component in relative_path.components() {
if matches!(component, std::path::Component::ParentDir) {
return Err("File path is outside the vault".into());
}
@@ -151,9 +172,10 @@ fn ensure_path_within_vault(vault: &Path, relative_path: &str, abs: &Path) -> Re
}
}
fn load_file_status(vault: &Path, relative_path: &str) -> Result<String, String> {
let output = Command::new("git")
.args(["status", "--porcelain", "--", relative_path])
fn load_file_status(vault: &Path, relative_path: &Path) -> Result<String, String> {
let output = git_command()
.args(["status", "--porcelain", "--"])
.arg(relative_path)
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {e}"))?;
@@ -166,14 +188,16 @@ fn load_file_status(vault: &Path, relative_path: &str) -> Result<String, String>
.unwrap_or_default())
}
fn restore_tracked_file(vault: &Path, relative_path: &str) -> Result<(), String> {
let _ = Command::new("git")
.args(["reset", "HEAD", "--", relative_path])
fn restore_tracked_file(vault: &Path, relative_path: &Path) -> Result<(), String> {
let _ = git_command()
.args(["reset", "HEAD", "--"])
.arg(relative_path)
.current_dir(vault)
.output();
let checkout = Command::new("git")
.args(["checkout", "--", relative_path])
let checkout = git_command()
.args(["checkout", "--"])
.arg(relative_path)
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
@@ -189,7 +213,7 @@ fn restore_tracked_file(vault: &Path, relative_path: &str) -> Result<(), String>
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
let output = git_command()
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(vault)
.output()
@@ -217,14 +241,14 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
return None;
}
let status = status_label(status_code);
let status = FileChangeStatus::from_code(status_code);
let full_path = vault.join(&relative_path).to_string_lossy().to_string();
let stats = resolve_diff_stats(vault, &relative_path, status, &diff_stats);
let stats = resolve_diff_stats(vault, Path::new(&relative_path), status, &diff_stats);
Some(ModifiedFile {
path: full_path,
relative_path,
status: status.to_string(),
status: status.label().to_string(),
added_lines: stats.added_lines,
deleted_lines: stats.deleted_lines,
binary: stats.binary,
@@ -244,10 +268,11 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
/// returned by [`get_modified_files`]).
pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let abs = vault.join(relative_path);
let relative = Path::new(relative_path);
let abs = vault.join(relative);
ensure_path_within_vault(vault, relative_path, &abs)?;
let status_code = load_file_status(vault, relative_path)?;
ensure_path_within_vault(vault, relative, &abs)?;
let status_code = load_file_status(vault, relative)?;
match status_code.as_str() {
"??" => {
@@ -255,7 +280,7 @@ pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(),
.map_err(|e| format!("Failed to delete untracked file: {e}"))?;
}
_ => {
restore_tracked_file(vault, relative_path)?;
restore_tracked_file(vault, relative)?;
}
}
@@ -264,11 +289,11 @@ pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(),
#[cfg(test)]
mod tests {
use super::git_command;
use super::*;
use crate::git::git_commit;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
fn write_and_commit_markdown(vault: &Path, vp: &str, relative_path: &str, content: &str) {
fs::write(vault.join(relative_path), content).unwrap();
@@ -282,12 +307,12 @@ mod tests {
// Create and commit a file
fs::write(vault.join("note.md"), "# Note\n").unwrap();
Command::new("git")
git_command()
.args(["add", "note.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Add note"])
.current_dir(vault)
.output()
@@ -327,12 +352,12 @@ mod tests {
// Create initial commit so git is initialized
fs::write(vault.join("init.md"), "# Init\n").unwrap();
Command::new("git")
git_command()
.args(["add", "init.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
git_command()
.args(["commit", "-m", "Initial"])
.current_dir(vault)
.output()

View File

@@ -12,15 +12,82 @@ pub mod settings;
pub mod telemetry;
pub mod vault;
pub mod vault_list;
#[cfg(desktop)]
mod window_state;
use std::ffi::OsStr;
use std::process::Command;
#[cfg(desktop)]
use std::path::{Path, PathBuf};
#[cfg(desktop)]
use std::process::Child;
#[cfg(desktop)]
use std::sync::Mutex;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
pub(crate) fn hidden_command(program: impl AsRef<OsStr>) -> Command {
let mut command = Command::new(program);
suppress_windows_console(&mut command);
command
}
#[cfg(windows)]
fn suppress_windows_console(command: &mut Command) {
use std::os::windows::process::CommandExt;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn suppress_windows_console(_command: &mut Command) {}
#[cfg(desktop)]
struct WsBridgeChild(Mutex<Option<Child>>);
#[cfg(desktop)]
struct ActiveAssetScopeRoots(Mutex<Vec<PathBuf>>);
#[cfg(any(test, all(desktop, target_os = "linux")))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StartupEnvOverride {
key: &'static str,
value: &'static str,
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn linux_appimage_startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
where
F: FnMut(&str) -> Option<String>,
{
let is_appimage = ["APPIMAGE", "APPDIR"]
.into_iter()
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()));
if !is_appimage {
return Vec::new();
}
if get_var("WEBKIT_DISABLE_DMABUF_RENDERER").is_some_and(|value| !value.trim().is_empty()) {
return Vec::new();
}
vec![StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
}]
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_linux_appimage_startup_env_overrides() {
for env_override in linux_appimage_startup_env_overrides_with(|key| std::env::var(key).ok()) {
std::env::set_var(env_override.key, env_override.value);
}
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn apply_linux_appimage_startup_env_overrides() {}
#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
@@ -48,12 +115,6 @@ fn run_startup_tasks() {
vault::migrate_agents_md(vp_str);
// Seed AGENTS.md and starter type definitions at vault root if missing
vault::seed_config_files(vp_str);
// Register Tolaria MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
Ok(status) => log::info!("MCP registration: {status}"),
Err(e) => log::warn!("MCP registration failed: {e}"),
}
}
#[cfg(desktop)]
@@ -92,11 +153,31 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
app.handle().plugin(tauri_plugin_opener::init())?;
#[cfg(not(target_os = "linux"))]
menu::setup_menu(app)?;
setup_linux_window_chrome(app)?;
window_state::restore_main_window_state(app);
Ok(())
}
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O"];
#[cfg(all(desktop, target_os = "linux"))]
fn setup_linux_window_chrome(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
use tauri::Manager;
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_decorations(false);
}
Ok(())
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn setup_linux_window_chrome(_app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O", "F"];
#[cfg(any(test, all(desktop, target_os = "macos")))]
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
#[cfg(all(desktop, target_os = "macos"))]
@@ -148,17 +229,66 @@ fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
#[cfg(desktop)]
fn vault_asset_scope_roots(vault_path: &Path) -> Result<Vec<PathBuf>, String> {
let canonical_vault_path = std::fs::canonicalize(vault_path).map_err(|e| {
format!(
"Failed to resolve asset scope for {}: {e}",
vault_path.display()
)
})?;
let mut roots = vec![canonical_vault_path.clone()];
let requested_vault_path = vault_path.to_path_buf();
if requested_vault_path != canonical_vault_path {
roots.push(requested_vault_path);
}
Ok(roots)
}
#[cfg(desktop)]
pub(crate) fn sync_vault_asset_scope(
app_handle: &tauri::AppHandle,
vault_path: &Path,
) -> Result<(), String> {
use tauri::Manager;
let next_roots = vault_asset_scope_roots(vault_path)?;
let scope = app_handle.asset_protocol_scope();
let state: tauri::State<'_, ActiveAssetScopeRoots> = app_handle.state();
let mut active_roots = state
.0
.lock()
.map_err(|_| "Failed to lock active asset scope state".to_string())?;
for root in &next_roots {
scope
.allow_directory(root, true)
.map_err(|e| format!("Failed to allow asset access for {}: {e}", root.display()))?;
}
for previous_root in active_roots.iter() {
if !next_roots.contains(previous_root) {
let _ = scope.forbid_directory(previous_root, true);
}
}
*active_roots = next_roots;
Ok(())
}
macro_rules! app_invoke_handler {
() => {
tauri::generate_handler![
commands::list_vault,
commands::list_vault_folders,
commands::get_note_content,
commands::create_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::rename_note_filename,
commands::move_note_to_folder,
commands::auto_rename_untitled,
commands::detect_renames,
commands::update_wikilinks_for_renames,
@@ -190,6 +320,7 @@ macro_rules! app_invoke_handler {
commands::stream_ai_agent,
commands::reload_vault,
commands::reload_vault_entry,
commands::sync_vault_asset_scope_for_window,
commands::sync_note_title,
commands::save_image,
commands::copy_image_to_vault,
@@ -198,23 +329,27 @@ macro_rules! app_invoke_handler {
commands::batch_delete_notes_async,
commands::migrate_is_a_to_type,
commands::create_vault_folder,
commands::rename_vault_folder,
commands::delete_vault_folder,
commands::batch_archive_notes,
commands::get_settings,
commands::check_for_app_update,
commands::update_menu_state,
commands::trigger_menu_command,
commands::update_current_window_min_size,
commands::perform_current_window_titlebar_double_click,
commands::save_settings,
commands::download_and_install_app_update,
commands::load_vault_list,
commands::save_vault_list,
commands::clone_repo,
commands::git_clone::clone_git_repo,
commands::search_vault,
commands::create_empty_vault,
commands::create_getting_started_vault,
commands::check_vault_exists,
commands::get_default_vault_path,
commands::register_mcp_tools,
commands::remove_mcp_tools,
commands::check_mcp_status,
commands::repair_vault,
commands::reinit_telemetry,
@@ -233,6 +368,8 @@ fn with_invoke_handler(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<ta
fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
use tauri::Manager;
window_state::handle_run_event(app_handle, event);
if let tauri::RunEvent::Exit = event {
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut guard = state.0.lock().unwrap();
@@ -245,10 +382,14 @@ fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
apply_linux_appimage_startup_env_overrides();
let builder = tauri::Builder::default();
#[cfg(desktop)]
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
let builder = builder
.manage(WsBridgeChild(Mutex::new(None)))
.manage(ActiveAssetScopeRoots(Mutex::new(Vec::new())))
.manage(window_state::MainWindowFrameState::default());
with_invoke_handler(builder)
.setup(setup_app)
@@ -262,10 +403,66 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::linux_appimage_startup_env_overrides_with;
use super::StartupEnvOverride;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
#[cfg(all(desktop, unix))]
use super::vault_asset_scope_roots;
#[test]
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_KEYS, ["O", "F"]);
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
#[test]
fn linux_appimage_startup_env_overrides_are_empty_outside_appimage_launches() {
let overrides = linux_appimage_startup_env_overrides_with(|_| None);
assert!(overrides.is_empty());
}
#[test]
fn linux_appimage_startup_env_overrides_disable_dmabuf_for_appimages() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
}]
);
}
#[test]
fn linux_appimage_startup_env_overrides_preserve_explicit_user_setting() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()),
_ => None,
});
assert!(overrides.is_empty());
}
#[cfg(all(desktop, unix))]
#[test]
fn vault_asset_scope_roots_include_requested_symlink_path() {
let dir = tempfile::tempdir().unwrap();
let canonical_vault = dir.path().join("Getting Started");
let symlinked_vault = dir.path().join("Symlinked Getting Started");
std::fs::create_dir(&canonical_vault).unwrap();
std::os::unix::fs::symlink(&canonical_vault, &symlinked_vault).unwrap();
let roots = vault_asset_scope_roots(&symlinked_vault).unwrap();
assert_eq!(roots[0], canonical_vault.canonicalize().unwrap());
assert!(roots.contains(&symlinked_vault));
}
}

View File

@@ -11,40 +11,116 @@ const LEGACY_MCP_SERVER_NAME: &str = "laputa";
pub enum McpStatus {
/// MCP is registered in Claude config and server files exist.
Installed,
/// MCP server files or config are missing but can be installed.
/// MCP server files or config are missing for the active vault.
NotInstalled,
/// Claude CLI is not installed — must install that first.
NoClaudeCli,
}
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = Command::new("which")
.arg("node")
let output = node_lookup_command()
.output()
.map_err(|e| format!("Failed to run `which node`: {e}"))?;
.map_err(|e| format!("Failed to locate node on PATH: {e}"))?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
if let Some(path) = first_node_lookup_path(&output.stdout) {
verify_node_version(&path)?;
return Ok(path);
}
}
if let Some(path) = fallback_node_path() {
verify_node_version(&path)?;
return Ok(path);
}
Err("node not found in PATH or common install locations".into())
}
fn first_node_lookup_path(stdout: &[u8]) -> Option<PathBuf> {
String::from_utf8_lossy(stdout)
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(PathBuf::from)
}
fn verify_node_version(node: &Path) -> Result<(), String> {
let output = crate::hidden_command(node)
.arg("--version")
.output()
.map_err(|e| format!("Failed to run {} --version: {e}", node.display()))?;
if !output.status.success() {
return Err(format!(
"{} --version failed; install Node.js 18+ and make it available on PATH",
node.display()
));
}
let raw_version = String::from_utf8_lossy(&output.stdout);
let Some(major) = node_major_version(&raw_version) else {
return Err(format!(
"Cannot parse Node.js version from '{}'",
raw_version.trim()
));
};
if major < 18 {
return Err(format!(
"Node.js 18+ is required for Tolaria MCP tools; found {}",
raw_version.trim()
));
}
Ok(())
}
fn node_major_version(version: &str) -> Option<u32> {
version
.trim()
.trim_start_matches('v')
.split('.')
.next()
.and_then(|major| major.parse().ok())
}
fn node_lookup_command() -> Command {
#[cfg(windows)]
let mut command = crate::hidden_command("where.exe");
#[cfg(not(windows))]
let mut command = crate::hidden_command("which");
command.arg("node");
command
}
fn fallback_node_path() -> Option<PathBuf> {
let mut candidates = vec![
PathBuf::from("/opt/homebrew/bin/node"),
PathBuf::from("/usr/local/bin/node"),
];
#[cfg(windows)]
{
if let Some(program_files) = std::env::var_os("ProgramFiles") {
candidates.push(PathBuf::from(program_files).join("nodejs").join("node.exe"));
}
if let Some(program_files_x86) = std::env::var_os("ProgramFiles(x86)") {
candidates.push(
PathBuf::from(program_files_x86)
.join("nodejs")
.join("node.exe"),
);
}
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
candidates.push(
PathBuf::from(local_app_data)
.join("Programs")
.join("nodejs")
.join("node.exe"),
);
}
}
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".volta").join("bin").join("node"));
candidates.push(home.join(".volta").join("bin").join(node_binary_name()));
let nvm_dir = home.join(".nvm").join("versions").join("node");
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
@@ -64,6 +140,14 @@ fn fallback_node_path() -> Option<PathBuf> {
candidates.into_iter().find(|path| path.is_file())
}
fn node_binary_name() -> &'static str {
if cfg!(windows) {
"node.exe"
} else {
"node"
}
}
/// Resolve the path to `mcp-server/`.
///
/// In dev mode, uses `CARGO_MANIFEST_DIR` (set at compile time).
@@ -72,36 +156,71 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("mcp-server");
if dev_path.join("ws-bridge.js").exists() {
return Ok(std::fs::canonicalize(&dev_path).unwrap_or(dev_path));
}
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
// On macOS the exe lives at Contents/MacOS/<binary>.
// Resources are placed at Contents/Resources/ by Tauri.
let release_path = exe
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("Resources").join("mcp-server"))
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
if release_path.join("ws-bridge.js").exists() {
return Ok(release_path);
let appdir = std::env::var_os("APPDIR").map(PathBuf::from);
let candidates = mcp_server_dir_candidates(&dev_path, &exe, appdir.as_deref());
if let Some(path) = candidates
.iter()
.find(|path| mcp_server_dir_has_files(path))
{
return Ok(std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()));
}
let searched = candidates
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"mcp-server not found at {} or {}",
dev_path.display(),
release_path.display()
"mcp-server not found. Searched these paths: {searched}"
))
}
fn mcp_server_dir_candidates(
dev_path: &Path,
exe_path: &Path,
appdir: Option<&Path>,
) -> Vec<PathBuf> {
let mut candidates = vec![dev_path.to_path_buf()];
if let Some(exe_dir) = exe_path.parent() {
candidates.push(exe_dir.join("mcp-server"));
if let Some(bundle_root) = exe_dir.parent() {
candidates.push(bundle_root.join("Resources").join("mcp-server"));
candidates.push(bundle_root.join("lib").join("tolaria").join("mcp-server"));
}
}
if let Some(appdir) = appdir {
candidates.push(
appdir
.join("usr")
.join("lib")
.join("tolaria")
.join("mcp-server"),
);
}
candidates.push(
PathBuf::from("/usr")
.join("lib")
.join("tolaria")
.join("mcp-server"),
);
candidates
}
fn mcp_server_dir_has_files(path: &Path) -> bool {
path.join("index.js").is_file() && path.join("ws-bridge.js").is_file()
}
/// Spawn the WebSocket bridge as a child process.
pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
let node = find_node()?;
let server_dir = mcp_server_dir()?;
let script = server_dir.join("ws-bridge.js");
let child = Command::new(node)
let child = crate::hidden_command(node)
.arg(&script)
.env("VAULT_PATH", vault_path)
.env("WS_PORT", "9710")
@@ -116,8 +235,19 @@ pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
Ok(child)
}
fn claude_mcp_config_path() -> Option<PathBuf> {
dirs::home_dir().map(|home| home.join(".claude").join("mcp.json"))
fn mcp_config_paths() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| mcp_config_paths_for_home(&home))
.unwrap_or_default()
}
fn mcp_config_paths_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
}
fn read_registered_mcp_entry(config_path: &Path) -> Option<serde_json::Value> {
@@ -142,12 +272,39 @@ fn entry_index_js_exists(entry: &serde_json::Value) -> bool {
.is_some_and(|index_js| Path::new(index_js).exists())
}
fn entry_uses_stdio(entry: &serde_json::Value) -> bool {
entry["type"].as_str() == Some("stdio")
}
fn entry_has_ui_port(entry: &serde_json::Value) -> bool {
entry["env"]["WS_UI_PORT"].as_str() == Some("9711")
}
fn entry_targets_vault(entry: &serde_json::Value, vault_path: &Path) -> bool {
let Some(entry_vault_path) = entry["env"]["VAULT_PATH"].as_str() else {
return false;
};
let Ok(expected) = std::fs::canonicalize(vault_path) else {
return false;
};
let Ok(actual) = std::fs::canonicalize(entry_vault_path) else {
return false;
};
actual == expected
}
/// Build the MCP server entry JSON for a given vault path and index.js path.
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
fn build_mcp_entry(node_command: &str, index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
"command": "node",
"type": "stdio",
"command": node_command,
"args": [index_js],
"env": { "VAULT_PATH": vault_path }
"env": {
"VAULT_PATH": vault_path,
"WS_UI_PORT": "9711"
}
})
}
@@ -165,22 +322,18 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
status.to_string()
}
/// Register Tolaria as an MCP server in Claude Code and Cursor config files.
/// Register Tolaria as an MCP server in external AI tool config files.
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let node = find_node().map_err(|e| {
format!("Node.js 18+ is required on PATH before Tolaria can register MCP tools: {e}")
})?;
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
let node_command = node.to_string_lossy().into_owned();
let entry = build_mcp_entry(&index_js, vault_path);
let entry = build_mcp_entry(&node_command, &index_js, vault_path);
let configs: Vec<PathBuf> = [
dirs::home_dir().map(|h| h.join(".claude").join("mcp.json")),
dirs::home_dir().map(|h| h.join(".cursor").join("mcp.json")),
]
.into_iter()
.flatten()
.collect();
Ok(register_mcp_to_configs(&entry, &configs))
Ok(register_mcp_to_configs(&entry, &mcp_config_paths()))
}
/// Insert or update the Tolaria entry in an MCP config file.
@@ -222,29 +375,82 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
Ok(was_update)
}
fn remove_mcp_from_configs(config_paths: &[PathBuf]) -> String {
let mut removed_any = false;
for config_path in config_paths {
match remove_mcp_from_config(config_path) {
Ok(true) => removed_any = true,
Ok(false) => {}
Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e),
}
}
if removed_any {
"removed".to_string()
} else {
"already_absent".to_string()
}
}
fn remove_mcp_from_config(config_path: &Path) -> Result<bool, String> {
if !config_path.exists() {
return Ok(false);
}
let raw = std::fs::read_to_string(config_path)
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
let mut config: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))?;
let Some(config_object) = config.as_object_mut() else {
return Err("Config is not a JSON object".into());
};
let Some(servers_value) = config_object.get_mut("mcpServers") else {
return Ok(false);
};
let Some(servers) = servers_value.as_object_mut() else {
return Err("mcpServers is not a JSON object".into());
};
let removed_primary = servers.remove(MCP_SERVER_NAME).is_some();
let removed_legacy = servers.remove(LEGACY_MCP_SERVER_NAME).is_some();
if !removed_primary && !removed_legacy {
return Ok(false);
}
if servers.is_empty() {
config_object.remove("mcpServers");
}
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(config_path, json)
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?;
Ok(true)
}
pub fn remove_mcp() -> String {
remove_mcp_from_configs(&mcp_config_paths())
}
/// Check whether the MCP server is properly installed and registered.
///
/// Returns `Installed` when the Tolaria entry exists in `~/.claude/mcp.json`
/// and the referenced index.js file is present. Returns `NoClaudeCli` when
/// the Claude CLI binary cannot be found. Otherwise returns `NotInstalled`.
pub fn check_mcp_status() -> McpStatus {
// Check Claude CLI first — no point installing MCP if Claude isn't available
if crate::claude_cli::find_claude_binary().is_err() {
return McpStatus::NoClaudeCli;
}
let Some(config_path) = claude_mcp_config_path() else {
return McpStatus::NotInstalled;
};
if !config_path.exists() {
return McpStatus::NotInstalled;
}
let Some(entry) = read_registered_mcp_entry(&config_path) else {
return McpStatus::NotInstalled;
};
if entry_index_js_exists(&entry) {
/// Returns `Installed` when the Tolaria entry exists for the active vault in
/// an external AI tool config and the referenced index.js file is present.
/// Otherwise returns `NotInstalled`.
pub fn check_mcp_status(vault_path: &str) -> McpStatus {
let active_vault_path = Path::new(vault_path);
if mcp_config_paths().into_iter().any(|config_path| {
read_registered_mcp_entry(&config_path).is_some_and(|entry| {
entry_uses_stdio(&entry)
&& entry_index_js_exists(&entry)
&& entry_has_ui_port(&entry)
&& entry_targets_vault(&entry, active_vault_path)
})
}) {
McpStatus::Installed
} else {
McpStatus::NotInstalled
@@ -260,19 +466,95 @@ mod tests {
serde_json::from_str(&raw).unwrap()
}
fn temp_config_path(file_name: &str) -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join(file_name);
(tmp, config_path)
}
fn write_config_json(config_path: &Path, config: serde_json::Value) {
std::fs::write(config_path, serde_json::to_string(&config).unwrap()).unwrap();
}
fn managed_server(index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
"type": "stdio",
"command": "node",
"args": [index_js],
"env": { "VAULT_PATH": vault_path, "WS_UI_PORT": "9711" }
})
}
fn test_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
build_mcp_entry("node", index_js, vault_path)
}
fn write_mcp_servers_config(config_path: &Path, servers: Vec<(&str, serde_json::Value)>) {
let servers = servers
.into_iter()
.map(|(name, server)| (name.to_string(), server))
.collect::<serde_json::Map<_, _>>();
write_config_json(config_path, serde_json::json!({ "mcpServers": servers }));
}
fn write_index_js(dir: &Path) -> PathBuf {
let index_js = dir.join("index.js");
std::fs::write(&index_js, "console.log('ok');").unwrap();
index_js
}
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
assert_eq!(entry["command"], "node");
let entry = build_mcp_entry("/usr/local/bin/node", "/path/to/index.js", "/my/vault");
assert_eq!(entry["type"], "stdio");
assert_eq!(entry["command"], "/usr/local/bin/node");
assert_eq!(entry["args"][0], "/path/to/index.js");
assert_eq!(entry["env"]["VAULT_PATH"], "/my/vault");
assert_eq!(entry["env"]["WS_UI_PORT"], "9711");
}
#[test]
fn first_node_lookup_path_uses_first_non_empty_line() {
let stdout = b"\nC:\\Program Files\\nodejs\\node.exe\r\nC:\\Other\\node.exe\r\n";
assert_eq!(
first_node_lookup_path(stdout).unwrap(),
PathBuf::from("C:\\Program Files\\nodejs\\node.exe")
);
}
#[test]
fn node_major_version_accepts_current_node_output() {
assert_eq!(node_major_version("v24.13.1\n"), Some(24));
assert_eq!(node_major_version("18.19.0"), Some(18));
assert_eq!(node_major_version("not-node"), None);
}
#[test]
fn mcp_server_dir_candidates_prefer_exe_dir_before_macos_resources() {
let dev_path = Path::new("/repo/mcp-server");
let exe_path = Path::new("/Users/tester/AppData/Local/Tolaria/tolaria.exe");
let candidates = mcp_server_dir_candidates(dev_path, exe_path, None);
let windows_dir = PathBuf::from("/Users/tester/AppData/Local/Tolaria/mcp-server");
let macos_dir = PathBuf::from("/Users/tester/AppData/Local/Resources/mcp-server");
let windows_pos = candidates
.iter()
.position(|path| path == &windows_dir)
.unwrap();
let macos_pos = candidates
.iter()
.position(|path| path == &macos_dir)
.unwrap();
assert_eq!(candidates[0], dev_path);
assert!(windows_pos < macos_pos);
}
#[test]
fn upsert_creates_new_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/test/vault");
let entry = test_mcp_entry("/test/index.js", "/test/vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(!was_update);
@@ -293,10 +575,10 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry1 = build_mcp_entry("/test/index.js", "/vault/v1");
let entry1 = test_mcp_entry("/test/index.js", "/vault/v1");
upsert_mcp_config(&config_path, &entry1).unwrap();
let entry2 = build_mcp_entry("/test/index.js", "/vault/v2");
let entry2 = test_mcp_entry("/test/index.js", "/vault/v2");
let was_update = upsert_mcp_config(&config_path, &entry2).unwrap();
assert!(was_update);
@@ -323,7 +605,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(was_update);
@@ -337,17 +619,16 @@ mod tests {
#[test]
fn upsert_preserves_other_servers() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let (_tmp, config_path) = temp_config_path("mcp.json");
write_mcp_servers_config(
&config_path,
vec![(
"other-server",
serde_json::json!({ "command": "other", "args": [] }),
)],
);
let existing = serde_json::json!({
"mcpServers": {
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
@@ -356,11 +637,35 @@ mod tests {
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
}
#[test]
fn upsert_preserves_other_top_level_settings() {
let (_tmp, config_path) = temp_config_path(".claude.json");
write_config_json(
&config_path,
serde_json::json!({
"model": "sonnet",
"theme": "dark",
"mcpServers": {
"other-server": { "command": "other", "args": [] }
}
}),
);
let entry = test_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
let config = read_config(&config_path);
assert_eq!(config["model"], "sonnet");
assert_eq!(config["theme"], "dark");
assert!(config["mcpServers"]["other-server"].is_object());
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
}
#[test]
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
assert!(config_path.exists());
@@ -370,7 +675,7 @@ mod tests {
fn register_mcp_to_configs_returns_registered_for_new() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("claude").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "registered");
@@ -380,7 +685,7 @@ mod tests {
fn register_mcp_to_configs_returns_updated_for_existing() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
// First call
register_mcp_to_configs(&entry, std::slice::from_ref(&config));
@@ -424,35 +729,63 @@ mod tests {
#[test]
fn register_mcp_to_configs_writes_multiple_configs() {
let tmp = tempfile::tempdir().unwrap();
let claude_user_cfg = tmp.path().join(".claude.json");
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let generic_cfg = tmp.path().join(".config").join("mcp").join("mcp.json");
let entry = test_mcp_entry("/test/index.js", "/vault");
register_mcp_to_configs(&entry, &[claude_cfg.clone(), cursor_cfg.clone()]);
register_mcp_to_configs(
&entry,
&[
claude_user_cfg.clone(),
claude_cfg.clone(),
cursor_cfg.clone(),
generic_cfg.clone(),
],
);
assert!(claude_user_cfg.exists());
assert!(claude_cfg.exists());
assert!(cursor_cfg.exists());
assert!(generic_cfg.exists());
let raw = std::fs::read_to_string(&claude_cfg).unwrap();
let raw = std::fs::read_to_string(&claude_user_cfg).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
);
}
#[test]
fn mcp_config_paths_for_home_includes_all_supported_config_paths() {
let home = Path::new("/Users/tester");
let paths = mcp_config_paths_for_home(home);
assert_eq!(
paths,
vec![
home.join(".claude.json"),
home.join(".claude").join("mcp.json"),
home.join(".cursor").join("mcp.json"),
home.join(".config").join("mcp").join("mcp.json"),
]
);
}
#[test]
fn upsert_returns_error_for_invalid_json() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "not valid json{{{{").unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(result.is_err());
}
#[test]
fn register_mcp_to_configs_handles_empty_list() {
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
// Empty config list — function should return "registered" (no existing)
let status = register_mcp_to_configs(&entry, &[]);
// With empty config list, there were no updates, so status should be "registered"
@@ -461,23 +794,20 @@ mod tests {
#[test]
fn read_registered_mcp_entry_prefers_primary_server_name() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": {
"command": "node",
"args": ["/primary/index.js"],
"env": { "VAULT_PATH": "/primary" }
},
"laputa": {
"command": "node",
"args": ["/legacy/index.js"],
"env": { "VAULT_PATH": "/legacy" }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let (_tmp, config_path) = temp_config_path("mcp.json");
write_mcp_servers_config(
&config_path,
vec![
(
MCP_SERVER_NAME,
managed_server("/primary/index.js", "/primary"),
),
(
LEGACY_MCP_SERVER_NAME,
managed_server("/legacy/index.js", "/legacy"),
),
],
);
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert_eq!(entry["env"]["VAULT_PATH"], "/primary");
@@ -485,18 +815,14 @@ mod tests {
#[test]
fn read_registered_mcp_entry_uses_legacy_server_name() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"laputa": {
"command": "node",
"args": ["/legacy/index.js"],
"env": { "VAULT_PATH": "/legacy" }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let (_tmp, config_path) = temp_config_path("mcp.json");
write_mcp_servers_config(
&config_path,
vec![(
LEGACY_MCP_SERVER_NAME,
managed_server("/legacy/index.js", "/legacy"),
)],
);
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert_eq!(entry["env"]["VAULT_PATH"], "/legacy");
@@ -544,7 +870,7 @@ mod tests {
let config_path = tmp.path().join("mcp.json");
std::fs::write(&config_path, "[]").unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(matches!(result, Err(ref error) if error.contains("Config is not a JSON object")));
}
@@ -558,7 +884,7 @@ mod tests {
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let entry = test_mcp_entry("/test/index.js", "/vault");
let result = upsert_mcp_config(&config_path, &entry);
assert!(
matches!(result, Err(ref error) if error.contains("mcpServers is not a JSON object"))
@@ -566,18 +892,79 @@ mod tests {
}
#[test]
fn check_mcp_status_returns_valid_variant() {
// On a dev machine with Claude CLI and MCP registered, this should be Installed.
// On CI without Claude it might be NoClaudeCli. Either way it must not panic.
let status = check_mcp_status();
assert!(
matches!(
status,
McpStatus::Installed | McpStatus::NotInstalled | McpStatus::NoClaudeCli
),
"unexpected status: {:?}",
status
);
fn remove_mcp_from_config_removes_primary_and_legacy_entries() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": { "command": "node", "args": ["/index.js"] },
"laputa": { "command": "node", "args": ["/legacy.js"] },
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let removed = remove_mcp_from_config(&config_path).unwrap();
assert!(removed);
let updated = read_config(&config_path);
assert!(updated["mcpServers"][MCP_SERVER_NAME].is_null());
assert!(updated["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null());
assert!(updated["mcpServers"]["other-server"].is_object());
}
#[test]
fn remove_mcp_from_config_returns_false_when_entry_missing() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let removed = remove_mcp_from_config(&config_path).unwrap();
assert!(!removed);
}
#[test]
fn check_mcp_status_returns_installed_for_matching_vault() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().join("vault");
std::fs::create_dir_all(&vault_path).unwrap();
let index_js = write_index_js(tmp.path());
let config_path = tmp.path().join("mcp.json");
let config = serde_json::json!({
"mcpServers": {
"tolaria": {
"command": "node",
"args": [index_js.to_string_lossy()],
"env": { "VAULT_PATH": vault_path.to_string_lossy() }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&config).unwrap()).unwrap();
let entry = read_registered_mcp_entry(&config_path).unwrap();
assert!(entry_targets_vault(&entry, &vault_path));
assert!(entry_index_js_exists(&entry));
}
#[test]
fn entry_targets_vault_requires_matching_existing_directory() {
let tmp = tempfile::tempdir().unwrap();
let first_vault = tmp.path().join("vault-a");
let second_vault = tmp.path().join("vault-b");
std::fs::create_dir_all(&first_vault).unwrap();
std::fs::create_dir_all(&second_vault).unwrap();
let entry = serde_json::json!({
"env": { "VAULT_PATH": first_vault.to_string_lossy() }
});
assert!(entry_targets_vault(&entry, &first_vault));
assert!(!entry_targets_vault(&entry, &second_vault));
}
#[test]
@@ -586,7 +973,5 @@ mod tests {
assert_eq!(json, r#""installed""#);
let json = serde_json::to_string(&McpStatus::NotInstalled).unwrap();
assert_eq!(json, r#""not_installed""#);
let json = serde_json::to_string(&McpStatus::NoClaudeCli).unwrap();
assert_eq!(json, r#""no_claude_cli""#);
}
}

View File

@@ -13,7 +13,10 @@ const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
const FILE_SAVE: &str = "file-save";
const EDIT_FIND_IN_NOTE: &str = "edit-find-in-note";
const EDIT_REPLACE_IN_NOTE: &str = "edit-replace-in-note";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search";
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff";
@@ -61,7 +64,10 @@ const CUSTOM_IDS: &[&str] = &[
FILE_QUICK_OPEN,
FILE_QUICK_OPEN_ALIAS,
FILE_SAVE,
EDIT_FIND_IN_NOTE,
EDIT_REPLACE_IN_NOTE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_NOTE_LIST_SEARCH,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
@@ -110,6 +116,12 @@ const NOTE_DEPENDENT_IDS: &[&str] = &[
NOTE_OPEN_IN_NEW_WINDOW,
];
/// IDs of menu items that depend on the editor being the active surface.
const EDITOR_FIND_DEPENDENT_IDS: &[&str] = &[EDIT_FIND_IN_NOTE, EDIT_REPLACE_IN_NOTE];
/// IDs of menu items that depend on the note list being the active surface.
const NOTE_LIST_SEARCH_DEPENDENT_IDS: &[&str] = &[EDIT_TOGGLE_NOTE_LIST_SEARCH];
/// IDs of menu items that depend on a deleted-note preview being active.
const RESTORE_DELETED_DEPENDENT_IDS: &[&str] = &[NOTE_RESTORE_DELETED];
@@ -151,6 +163,11 @@ fn build_app_menu(app: &App) -> MenuResult {
}
fn build_file_menu(app: &App) -> MenuResult {
let quick_open_alias_label = if cfg!(target_os = "macos") {
"Quick Open (Cmd+O)"
} else {
"Quick Open (Ctrl+O)"
};
let new_note = MenuItemBuilder::new("New Note")
.id(FILE_NEW_NOTE)
.accelerator("CmdOrCtrl+N")
@@ -162,7 +179,7 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_QUICK_OPEN)
.accelerator("CmdOrCtrl+P")
.build(app)?;
let quick_open_alias = MenuItemBuilder::new("Quick Open (Cmd+O)")
let quick_open_alias = MenuItemBuilder::new(quick_open_alias_label)
.id(FILE_QUICK_OPEN_ALIAS)
.accelerator("CmdOrCtrl+O")
.build(app)?;
@@ -181,10 +198,24 @@ fn build_file_menu(app: &App) -> MenuResult {
}
fn build_edit_menu(app: &App) -> MenuResult {
let find_in_note = MenuItemBuilder::new("Find in Note")
.id(EDIT_FIND_IN_NOTE)
.accelerator("CmdOrCtrl+F")
.enabled(false)
.build(app)?;
let replace_in_note = MenuItemBuilder::new("Replace in Note")
.id(EDIT_REPLACE_IN_NOTE)
.enabled(false)
.build(app)?;
let find_in_vault = MenuItemBuilder::new("Find in Vault")
.id(EDIT_FIND_IN_VAULT)
.accelerator("CmdOrCtrl+Shift+F")
.build(app)?;
let toggle_note_list_search = MenuItemBuilder::new("Toggle Note List Search")
.id(EDIT_TOGGLE_NOTE_LIST_SEARCH)
.accelerator("CmdOrCtrl+F")
.enabled(false)
.build(app)?;
let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode")
.id(EDIT_TOGGLE_DIFF)
.build(app)?;
@@ -199,7 +230,10 @@ fn build_edit_menu(app: &App) -> MenuResult {
.separator()
.select_all()
.separator()
.item(&find_in_note)
.item(&replace_in_note)
.item(&find_in_vault)
.item(&toggle_note_list_search)
.item(&toggle_diff)
.build()?)
}
@@ -309,7 +343,7 @@ fn build_note_menu(app: &App) -> MenuResult {
.build(app)?;
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Panel")
.id(VIEW_TOGGLE_AI_CHAT)
.accelerator("Cmd+Shift+L")
.accelerator("CmdOrCtrl+Shift+L")
.build(app)?;
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
.id(VIEW_TOGGLE_BACKLINKS)
@@ -356,7 +390,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
let view_changes = MenuItemBuilder::new("View Pending Changes")
.id(VAULT_VIEW_CHANGES)
.build(app)?;
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
let install_mcp = MenuItemBuilder::new("Set Up External AI Tools…")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reload = MenuItemBuilder::new("Reload Vault")
@@ -452,6 +486,16 @@ pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on the editor being the active surface.
pub fn set_editor_find_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, EDITOR_FIND_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on the note list being the active surface.
pub fn set_note_list_search_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_LIST_SEARCH_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on having uncommitted changes.
pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled);
@@ -485,7 +529,10 @@ mod tests {
FILE_NEW_TYPE,
FILE_QUICK_OPEN,
FILE_SAVE,
EDIT_FIND_IN_NOTE,
EDIT_REPLACE_IN_NOTE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_NOTE_LIST_SEARCH,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
@@ -532,6 +579,26 @@ mod tests {
}
}
#[test]
fn note_list_search_dependent_ids_are_subset_of_custom_ids() {
for id in NOTE_LIST_SEARCH_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"note-list-search-dependent ID {id} not in CUSTOM_IDS"
);
}
}
#[test]
fn editor_find_dependent_ids_are_subset_of_custom_ids() {
for id in EDITOR_FIND_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"editor-find-dependent ID {id} not in CUSTOM_IDS"
);
}
}
#[test]
fn git_dependent_ids_are_subset_of_custom_ids() {
for id in GIT_COMMIT_DEPENDENT_IDS {

View File

@@ -5,17 +5,20 @@ use std::path::PathBuf;
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct Settings {
pub auto_pull_interval_minutes: Option<u32>,
pub autogit_enabled: Option<bool>,
pub autogit_idle_threshold_seconds: Option<u32>,
pub autogit_inactive_threshold_seconds: Option<u32>,
pub auto_advance_inbox_after_organize: Option<bool>,
pub telemetry_consent: Option<bool>,
pub crash_reporting_enabled: Option<bool>,
pub analytics_enabled: Option<bool>,
pub anonymous_id: Option<String>,
pub release_channel: Option<String>,
pub theme_mode: Option<String>,
pub ui_language: Option<String>,
pub initial_h1_auto_rename_enabled: Option<bool>,
pub default_ai_agent: Option<String>,
}
@@ -52,6 +55,41 @@ pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
}
}
pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
Some(mode) if mode == "light" || mode == "dark" => Some(mode),
_ => None,
}
}
fn canonical_language_code(value: &str) -> Option<String> {
let code = value.trim().replace('_', "-").to_ascii_lowercase();
if code.is_empty() {
None
} else {
Some(code)
}
}
fn is_english_language(code: &str) -> bool {
code == "en" || code.starts_with("en-")
}
fn is_simplified_chinese_language(code: &str) -> bool {
matches!(code, "zh" | "zh-cn" | "zh-hans" | "zh-sg")
}
pub fn normalize_ui_language(value: Option<&str>) -> Option<String> {
let language = canonical_language_code(value?)?;
if is_english_language(&language) {
return Some("en".to_string());
}
if is_simplified_chinese_language(&language) {
return Some("zh-Hans".to_string());
}
None
}
fn normalize_settings(settings: Settings) -> Settings {
Settings {
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
@@ -62,11 +100,14 @@ fn normalize_settings(settings: Settings) -> Settings {
autogit_inactive_threshold_seconds: normalize_optional_positive_u32(
settings.autogit_inactive_threshold_seconds,
),
auto_advance_inbox_after_organize: settings.auto_advance_inbox_after_organize,
telemetry_consent: settings.telemetry_consent,
crash_reporting_enabled: settings.crash_reporting_enabled,
analytics_enabled: settings.analytics_enabled,
anonymous_id: normalize_optional_string(settings.anonymous_id),
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
}
@@ -76,7 +117,7 @@ fn app_config_dir() -> Result<PathBuf, String> {
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
}
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
pub(crate) fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
}
@@ -164,41 +205,8 @@ pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
mod tests {
use super::*;
type SettingsSnapshot<'a> = (
Option<u32>,
Option<bool>,
Option<u32>,
Option<u32>,
Option<bool>,
Option<bool>,
Option<bool>,
Option<&'a str>,
Option<&'a str>,
Option<bool>,
Option<&'a str>,
);
fn settings_snapshot(settings: &Settings) -> SettingsSnapshot<'_> {
(
settings.auto_pull_interval_minutes,
settings.autogit_enabled,
settings.autogit_idle_threshold_seconds,
settings.autogit_inactive_threshold_seconds,
settings.telemetry_consent,
settings.crash_reporting_enabled,
settings.analytics_enabled,
settings.anonymous_id.as_deref(),
settings.release_channel.as_deref(),
settings.initial_h1_auto_rename_enabled,
settings.default_ai_agent.as_deref(),
)
}
fn assert_empty_settings(settings: &Settings) {
assert_eq!(
settings_snapshot(settings),
(None, None, None, None, None, None, None, None, None, None, None)
);
assert_eq!(settings, &Settings::default());
}
/// Helper: save settings to a temp file and reload them.
@@ -234,17 +242,20 @@ mod tests {
autogit_enabled: Some(true),
autogit_idle_threshold_seconds: Some(90),
autogit_inactive_threshold_seconds: Some(30),
auto_advance_inbox_after_organize: Some(true),
telemetry_consent: Some(true),
crash_reporting_enabled: Some(true),
analytics_enabled: Some(false),
anonymous_id: Some("abc-123-uuid".to_string()),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
};
let json = serde_json::to_string(&settings).unwrap();
let parsed: Settings = serde_json::from_str(&json).unwrap();
assert_eq!(settings_snapshot(&parsed), settings_snapshot(&settings));
assert_eq!(parsed, settings);
}
#[test]
@@ -262,7 +273,10 @@ mod tests {
autogit_enabled: Some(true),
autogit_idle_threshold_seconds: Some(90),
autogit_inactive_threshold_seconds: Some(30),
auto_advance_inbox_after_organize: Some(true),
release_channel: Some("alpha".to_string()),
theme_mode: Some("dark".to_string()),
ui_language: Some("zh-Hans".to_string()),
initial_h1_auto_rename_enabled: Some(false),
default_ai_agent: Some("codex".to_string()),
..Default::default()
@@ -271,7 +285,10 @@ mod tests {
assert_eq!(loaded.autogit_enabled, Some(true));
assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90));
assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30));
assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
}
@@ -281,11 +298,15 @@ mod tests {
let loaded = save_and_reload(Settings {
anonymous_id: Some(" test-uuid ".to_string()),
release_channel: Some(" alpha ".to_string()),
theme_mode: Some(" dark ".to_string()),
ui_language: Some(" zh-cn ".to_string()),
default_ai_agent: Some(" codex ".to_string()),
..Default::default()
});
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid"));
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
assert_eq!(loaded.ui_language.as_deref(), Some("zh-Hans"));
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
}
@@ -327,6 +348,33 @@ mod tests {
assert!(loaded.default_ai_agent.is_none());
}
#[test]
fn test_invalid_theme_mode_is_filtered() {
let loaded = save_and_reload(Settings {
theme_mode: Some("system".to_string()),
..Default::default()
});
assert!(loaded.theme_mode.is_none());
}
#[test]
fn test_invalid_ui_language_is_filtered() {
let loaded = save_and_reload(Settings {
ui_language: Some("fr-FR".to_string()),
..Default::default()
});
assert!(loaded.ui_language.is_none());
}
#[test]
fn test_ui_language_aliases_are_canonicalized() {
assert_eq!(normalize_ui_language(Some("en-US")).as_deref(), Some("en"));
assert_eq!(
normalize_ui_language(Some("zh_CN")).as_deref(),
Some("zh-Hans")
);
}
#[test]
fn test_get_settings_normalizes_legacy_beta_channel() {
let dir = tempfile::TempDir::new().unwrap();
@@ -377,20 +425,14 @@ mod tests {
..Default::default()
});
assert_eq!(
settings_snapshot(&loaded),
(
None,
None,
None,
None,
Some(true),
Some(true),
Some(false),
Some("test-uuid-v4"),
None,
None,
None,
)
loaded,
Settings {
telemetry_consent: Some(true),
crash_reporting_enabled: Some(true),
analytics_enabled: Some(false),
anonymous_id: Some("test-uuid-v4".to_string()),
..Default::default()
}
);
}

View File

@@ -11,6 +11,39 @@ fn scrub_paths(input: &str) -> String {
re.replace_all(input, "<redacted-path>").to_string()
}
fn normalize_embedded_env(raw: Option<&str>) -> Option<String> {
let value = raw?.trim();
if value.is_empty() {
return None;
}
let unwrapped = match (value.chars().next(), value.chars().last()) {
(Some('"'), Some('"')) | (Some('\''), Some('\'')) if value.len() >= 2 => {
value[1..value.len() - 1].trim()
}
_ => value,
};
if unwrapped.is_empty() {
None
} else {
Some(unwrapped.to_string())
}
}
fn normalize_http_like_value(value: &str) -> String {
if value.contains("://") {
value.to_string()
} else {
format!("https://{value}")
}
}
fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
let normalized = normalize_embedded_env(raw)?;
normalize_http_like_value(&normalized).parse().ok()
}
/// Initialize Sentry if the user has opted in to crash reporting.
/// Returns `true` if Sentry was initialized, `false` if skipped.
pub fn init_sentry_from_settings() -> bool {
@@ -23,14 +56,13 @@ pub fn init_sentry_from_settings() -> bool {
return false;
}
let dsn = option_env!("SENTRY_DSN").unwrap_or_default();
if dsn.is_empty() {
let Some(dsn) = parse_embedded_sentry_dsn(option_env!("SENTRY_DSN")) else {
return false;
}
};
let anonymous_id = settings.anonymous_id.unwrap_or_default();
let guard = sentry::init(sentry::ClientOptions {
dsn: dsn.parse().ok(),
dsn: Some(dsn),
release: Some(env!("CARGO_PKG_VERSION").into()),
send_default_pii: false,
before_send: Some(std::sync::Arc::new(|mut event| {
@@ -86,6 +118,43 @@ mod tests {
assert_eq!(scrub_paths("Normal error message"), "Normal error message");
}
#[test]
fn test_normalize_embedded_env_trims_wrapping_quotes() {
assert_eq!(
normalize_embedded_env(Some(" \"value\" ")).as_deref(),
Some("value")
);
assert_eq!(
normalize_embedded_env(Some(" 'value' ")).as_deref(),
Some("value")
);
}
#[test]
fn test_normalize_embedded_env_drops_blank_values() {
assert_eq!(normalize_embedded_env(Some(" ")), None);
assert_eq!(normalize_embedded_env(None), None);
}
#[test]
fn test_parse_embedded_sentry_dsn_accepts_valid_trimmed_value() {
let parsed =
parse_embedded_sentry_dsn(Some(" \"https://public@example.ingest.sentry.io/1\" "));
assert!(parsed.is_some());
}
#[test]
fn test_parse_embedded_sentry_dsn_accepts_scheme_less_value() {
let parsed = parse_embedded_sentry_dsn(Some("public@example.ingest.sentry.io/1"));
assert!(parsed.is_some());
}
#[test]
fn test_parse_embedded_sentry_dsn_rejects_invalid_value() {
let parsed = parse_embedded_sentry_dsn(Some("not a dsn"));
assert!(parsed.is_none());
}
#[test]
fn test_init_sentry_returns_false_without_dsn() {
// Without SENTRY_DSN env var set at compile time, init should return false

View File

@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::fs::{self, OpenOptions};
use std::hash::{Hash, Hasher};
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use uuid::Uuid;
use crate::git::{get_all_file_dates, GitDates};
use std::collections::HashMap;
@@ -14,8 +17,9 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry
/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items)
/// v13: preserve plain square brackets in parsed markdown H1 titles
const CACHE_VERSION: u32 = 13;
const CACHE_WRITE_LOCK_STALE_SECS: u64 = 30;
#[derive(Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
struct VaultCache {
#[serde(default = "default_cache_version")]
version: u32,
@@ -27,6 +31,51 @@ struct VaultCache {
entries: Vec<VaultEntry>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CacheFileFingerprint {
byte_len: usize,
content_hash: u64,
}
#[derive(Debug)]
struct LoadedCache {
cache: VaultCache,
fingerprint: CacheFileFingerprint,
}
#[derive(Debug)]
enum CacheLoadState {
Missing,
Loaded(LoadedCache),
Invalid(String),
Unreadable(String),
}
#[derive(Debug, Eq, PartialEq)]
enum CacheWriteOutcome {
Replaced,
SkippedConcurrentUpdate,
SkippedActiveWriter,
}
struct CacheWriteLock {
path: PathBuf,
}
impl Drop for CacheWriteLock {
fn drop(&mut self) {
if let Err(error) = fs::remove_file(&self.path) {
if error.kind() != ErrorKind::NotFound {
log::warn!(
"Failed to release cache write lock {}: {}",
self.path.display(),
error
);
}
}
}
}
fn default_cache_version() -> u32 {
1
}
@@ -53,6 +102,18 @@ fn cache_path(vault: &Path) -> PathBuf {
cache_dir().join(format!("{}.json", vault_path_hash(vault)))
}
fn cache_lock_path(vault: &Path) -> PathBuf {
cache_path(vault).with_extension("lock")
}
fn cache_temp_path(final_path: &Path) -> PathBuf {
let file_name = final_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("cache.json");
final_path.with_file_name(format!("{file_name}.{}.tmp", Uuid::new_v4()))
}
/// Legacy cache path inside the vault directory (pre-migration).
fn legacy_cache_path(vault: &Path) -> PathBuf {
vault.join(".laputa-cache.json")
@@ -64,7 +125,7 @@ fn git_head_hash(vault: &Path) -> Option<String> {
/// Run a git command in the given directory and return stdout if successful.
fn run_git(vault: &Path, args: &[&str]) -> Option<String> {
let output = std::process::Command::new("git")
let output = crate::hidden_command("git")
.args(args)
.current_dir(vault)
.output()
@@ -156,25 +217,234 @@ fn git_uncommitted_files(vault: &Path) -> Vec<String> {
files
}
fn load_cache(vault: &Path) -> Option<VaultCache> {
let data = fs::read_to_string(cache_path(vault)).ok()?;
serde_json::from_str(&data).ok()
fn cache_fingerprint(bytes: &[u8]) -> CacheFileFingerprint {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
CacheFileFingerprint {
byte_len: bytes.len(),
content_hash: hasher.finish(),
}
}
/// Write cache atomically: write to a temp file then rename.
fn write_cache(vault: &Path, cache: &VaultCache) {
let final_path = cache_path(vault);
if let Some(parent) = final_path.parent() {
let _ = fs::create_dir_all(parent);
fn read_cache_bytes(path: &Path) -> Result<Option<Vec<u8>>, String> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!(
"Failed to read cache {}: {}",
path.display(),
error
)),
}
let tmp_path = final_path.with_extension("tmp");
if let Ok(data) = serde_json::to_string(cache) {
if fs::write(&tmp_path, &data).is_ok() {
let _ = fs::rename(&tmp_path, &final_path);
}
fn read_cache_fingerprint(path: &Path) -> Result<Option<CacheFileFingerprint>, String> {
Ok(read_cache_bytes(path)?.map(|bytes| cache_fingerprint(&bytes)))
}
fn load_cache(vault: &Path) -> CacheLoadState {
let path = cache_path(vault);
let Some(bytes) = (match read_cache_bytes(&path) {
Ok(bytes) => bytes,
Err(error) => return CacheLoadState::Unreadable(error),
}) else {
return CacheLoadState::Missing;
};
let fingerprint = cache_fingerprint(&bytes);
match serde_json::from_slice(&bytes) {
Ok(cache) => CacheLoadState::Loaded(LoadedCache { cache, fingerprint }),
Err(error) => CacheLoadState::Invalid(format!(
"Failed to parse cache {}: {}",
path.display(),
error
)),
}
}
fn lock_is_stale(lock_path: &Path) -> bool {
fs::metadata(lock_path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(|modified| modified.elapsed().ok())
.map(|elapsed| elapsed > Duration::from_secs(CACHE_WRITE_LOCK_STALE_SECS))
.unwrap_or(false)
}
fn ensure_cache_parent_dir(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"Failed to create cache directory {}: {}",
parent.display(),
error
)
})?;
}
Ok(())
}
fn initialize_cache_write_lock(
mut file: fs::File,
lock_path: &Path,
) -> Result<CacheWriteLock, String> {
let pid = std::process::id().to_string();
if let Err(error) = file.write_all(pid.as_bytes()).and_then(|_| file.sync_all()) {
let _ = fs::remove_file(lock_path);
return Err(format!(
"Failed to initialize cache write lock {}: {}",
lock_path.display(),
error
));
}
Ok(CacheWriteLock {
path: lock_path.to_path_buf(),
})
}
fn try_create_cache_write_lock(lock_path: &Path) -> Result<Option<CacheWriteLock>, String> {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(lock_path)
{
Ok(file) => initialize_cache_write_lock(file, lock_path).map(Some),
Err(error) if error.kind() == ErrorKind::AlreadyExists => Ok(None),
Err(error) => Err(format!(
"Failed to acquire cache write lock {}: {}",
lock_path.display(),
error
)),
}
}
fn remove_stale_cache_write_lock(lock_path: &Path) -> Result<bool, String> {
if !lock_is_stale(lock_path) {
return Ok(false);
}
log::warn!("Removing stale cache write lock {}", lock_path.display());
match fs::remove_file(lock_path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(true),
Err(error) => Err(format!(
"Failed to remove stale cache write lock {}: {}",
lock_path.display(),
error
)),
}
}
fn acquire_cache_write_lock(lock_path: &Path) -> Result<Option<CacheWriteLock>, String> {
ensure_cache_parent_dir(lock_path)?;
if let Some(lock) = try_create_cache_write_lock(lock_path)? {
return Ok(Some(lock));
}
if !remove_stale_cache_write_lock(lock_path)? {
return Ok(None);
}
try_create_cache_write_lock(lock_path)
}
fn remove_cache_file(path: &Path, reason: &str) {
if let Err(error) = fs::remove_file(path) {
if error.kind() != ErrorKind::NotFound {
log::warn!("Failed to remove {reason} {}: {}", path.display(), error);
}
}
}
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> Result<(), String> {
let Some(parent) = path.parent() else {
return Ok(());
};
fs::File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(|error| {
format!(
"Failed to sync cache directory {}: {}",
parent.display(),
error
)
})
}
#[cfg(not(unix))]
fn sync_parent_directory(_path: &Path) -> Result<(), String> {
Ok(())
}
/// Replace the cache file using a temp file + rename, but only if the on-disk
/// cache still matches the version we loaded earlier.
fn write_cache(
vault: &Path,
cache: &VaultCache,
expected_previous: Option<CacheFileFingerprint>,
) -> Result<CacheWriteOutcome, String> {
let final_path = cache_path(vault);
let lock_path = cache_lock_path(vault);
let Some(_lock) = acquire_cache_write_lock(&lock_path)? else {
return Ok(CacheWriteOutcome::SkippedActiveWriter);
};
let current_fingerprint = read_cache_fingerprint(&final_path)?;
let still_matches_loaded_state = match expected_previous.as_ref() {
Some(expected) => current_fingerprint.as_ref() == Some(expected),
None => current_fingerprint.is_none(),
};
if !still_matches_loaded_state {
return Ok(CacheWriteOutcome::SkippedConcurrentUpdate);
}
ensure_cache_parent_dir(&final_path)?;
let data = serde_json::to_vec(cache).map_err(|error| {
format!(
"Failed to serialize cache {}: {}",
final_path.display(),
error
)
})?;
let tmp_path = cache_temp_path(&final_path);
let mut tmp_file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
.map_err(|error| {
format!(
"Failed to create temp cache file {}: {}",
tmp_path.display(),
error
)
})?;
if let Err(error) = tmp_file.write_all(&data).and_then(|_| tmp_file.sync_all()) {
remove_cache_file(&tmp_path, "temp cache file");
return Err(format!(
"Failed to flush temp cache file {}: {}",
tmp_path.display(),
error
));
}
drop(tmp_file);
if let Err(error) = fs::rename(&tmp_path, &final_path) {
remove_cache_file(&tmp_path, "temp cache file");
return Err(format!(
"Failed to replace cache {}: {}",
final_path.display(),
error
));
}
if let Err(error) = sync_parent_directory(&final_path) {
log::warn!("{error}");
}
Ok(CacheWriteOutcome::Replaced)
}
/// Normalize an absolute path to a relative path for comparison with git output.
fn to_relative_path(abs_path: &str, vault: &Path) -> String {
let vault_str = vault.to_string_lossy();
@@ -213,7 +483,7 @@ fn parse_files_at(
.collect()
}
/// Copy legacy cache data to the new external location atomically.
/// Copy legacy cache data to the new external location via temp file + rename.
fn copy_legacy_cache_to(legacy: &Path, dest: &Path) {
if let Some(parent) = dest.parent() {
let _ = fs::create_dir_all(parent);
@@ -240,7 +510,7 @@ fn migrate_legacy_cache(vault: &Path) {
}
// Remove legacy file from git tracking if present
let _ = std::process::Command::new("git")
let _ = crate::hidden_command("git")
.args([
"rm",
"--cached",
@@ -272,10 +542,15 @@ fn prune_stale_entries(vault: &Path, entries: &mut Vec<VaultEntry>) -> bool {
}
/// Sort entries by modified_at descending and write the cache.
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
fn finalize_and_cache(
vault: &Path,
mut entries: Vec<VaultEntry>,
hash: String,
expected_previous: Option<CacheFileFingerprint>,
) -> Vec<VaultEntry> {
prune_stale_entries(vault, &mut entries);
entries.sort_by_key(|entry| std::cmp::Reverse(entry.modified_at));
write_cache(
let outcome = write_cache(
vault,
&VaultCache {
version: CACHE_VERSION,
@@ -283,7 +558,20 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
commit_hash: hash,
entries: entries.clone(),
},
expected_previous,
);
match outcome {
Ok(CacheWriteOutcome::Replaced) => {}
Ok(CacheWriteOutcome::SkippedConcurrentUpdate) => log::info!(
"Skipped replacing cache {} because another scan refreshed it first",
cache_path(vault).display()
),
Ok(CacheWriteOutcome::SkippedActiveWriter) => log::info!(
"Skipped replacing cache {} because another writer is active",
cache_path(vault).display()
),
Err(error) => log::warn!("{error}"),
}
entries
}
@@ -292,9 +580,10 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
/// deleted outside git (e.g., via Finder) are removed from the cache on vault open.
fn update_same_commit(
vault: &Path,
cache: VaultCache,
loaded_cache: LoadedCache,
git_dates: &HashMap<String, GitDates>,
) -> Vec<VaultEntry> {
let LoadedCache { cache, fingerprint } = loaded_cache;
let changed = git_uncommitted_files(vault);
let mut entries = cache.entries;
if !changed.is_empty() {
@@ -304,16 +593,17 @@ fn update_same_commit(
}
// Always finalize: prune_stale_entries inside finalize_and_cache removes
// entries for files deleted outside git (e.g., via Finder or another app).
finalize_and_cache(vault, entries, cache.commit_hash)
finalize_and_cache(vault, entries, cache.commit_hash, Some(fingerprint))
}
/// Handle different-commit cache: incremental update via git diff.
fn update_different_commit(
vault: &Path,
cache: VaultCache,
loaded_cache: LoadedCache,
current_hash: String,
git_dates: &HashMap<String, GitDates>,
) -> Vec<VaultEntry> {
let LoadedCache { cache, fingerprint } = loaded_cache;
let changed_files = git_changed_files(vault, &cache.commit_hash, &current_hash);
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
@@ -324,7 +614,7 @@ fn update_different_commit(
.collect();
entries.extend(parse_files_at(vault, &changed_files, git_dates));
finalize_and_cache(vault, entries, current_hash)
finalize_and_cache(vault, entries, current_hash, Some(fingerprint))
}
fn cache_requires_full_rescan(cache: &VaultCache, vault_path: &Path) -> bool {
@@ -337,9 +627,15 @@ fn scan_and_cache_full(
vault_path: &Path,
git_dates: &HashMap<String, GitDates>,
current_hash: String,
expected_previous: Option<CacheFileFingerprint>,
) -> Result<Vec<VaultEntry>, String> {
let entries = scan_vault(vault_path, git_dates)?;
Ok(finalize_and_cache(vault_path, entries, current_hash))
Ok(finalize_and_cache(
vault_path,
entries,
current_hash,
expected_previous,
))
}
/// Delete the cache file for a vault, forcing a full rescan on the next
@@ -347,7 +643,7 @@ fn scan_and_cache_full(
/// explicit user-triggered reloads always read from the filesystem.
pub fn invalidate_cache(vault_path: &Path) {
let path = cache_path(vault_path);
let _ = fs::remove_file(&path);
remove_cache_file(&path, "cache file");
}
/// Scan vault with incremental caching via git.
@@ -371,24 +667,37 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
// Build git dates map once — used by all code paths below
let git_dates = get_all_file_dates(vault_path);
if let Some(cache) = load_cache(vault_path) {
if cache_requires_full_rescan(&cache, vault_path) {
return scan_and_cache_full(vault_path, &git_dates, current_hash);
match load_cache(vault_path) {
CacheLoadState::Missing => {}
CacheLoadState::Unreadable(error) => log::warn!("{error}"),
CacheLoadState::Invalid(error) => {
log::warn!("{error}");
remove_cache_file(&cache_path(vault_path), "invalid cache file");
}
CacheLoadState::Loaded(loaded_cache) => {
if cache_requires_full_rescan(&loaded_cache.cache, vault_path) {
return scan_and_cache_full(
vault_path,
&git_dates,
current_hash,
Some(loaded_cache.fingerprint),
);
}
return if loaded_cache.cache.commit_hash == current_hash {
Ok(update_same_commit(vault_path, loaded_cache, &git_dates))
} else {
Ok(update_different_commit(
vault_path,
loaded_cache,
current_hash,
&git_dates,
))
};
}
return if cache.commit_hash == current_hash {
Ok(update_same_commit(vault_path, cache, &git_dates))
} else {
Ok(update_different_commit(
vault_path,
cache,
current_hash,
&git_dates,
))
};
}
// No cache — full scan and write cache
scan_and_cache_full(vault_path, &git_dates, current_hash)
scan_and_cache_full(vault_path, &git_dates, current_hash, None)
}
#[cfg(test)]
@@ -418,17 +727,17 @@ mod tests {
}
fn init_git_repo(vault: &Path) {
std::process::Command::new("git")
crate::hidden_command("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
crate::hidden_command("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
crate::hidden_command("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
@@ -447,12 +756,12 @@ mod tests {
}
fn git_add_commit(vault: &Path, msg: &str) {
std::process::Command::new("git")
crate::hidden_command("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
crate::hidden_command("git")
.args(["commit", "-m", msg])
.current_dir(vault)
.output()
@@ -499,7 +808,7 @@ mod tests {
}
#[test]
fn test_atomic_write_no_tmp_file_left() {
fn test_cache_write_no_tmp_file_left() {
let _lock = ENV_LOCK.lock().unwrap();
let cache_dir = TempDir::new().unwrap();
set_test_cache_dir(cache_dir.path());
@@ -514,18 +823,19 @@ mod tests {
entries: vec![],
};
write_cache(vault, &cache);
write_cache(vault, &cache, None).unwrap();
// Final file should exist
let final_path = cache_path(vault);
assert!(final_path.exists(), "cache file must exist after write");
// Tmp file should NOT exist (renamed away)
let tmp_path = final_path.with_extension("tmp");
assert!(
!tmp_path.exists(),
"tmp file must not exist after atomic write"
);
// Tmp files should NOT remain beside the cache file
let tmp_count = fs::read_dir(cache_dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().contains(".tmp"))
.count();
assert_eq!(tmp_count, 0, "cache write must not leave tmp files behind");
// Content must be valid JSON
let data = fs::read_to_string(&final_path).unwrap();
@@ -811,7 +1121,7 @@ mod tests {
// Delete file via filesystem (simulates Finder delete)
fs::remove_file(vault.join("remove.md")).unwrap();
// Also stage the deletion so git status is clean for this file
std::process::Command::new("git")
crate::hidden_command("git")
.args(["add", "remove.md"])
.current_dir(vault)
.output()
@@ -972,7 +1282,7 @@ mod tests {
commit_hash: hash,
entries: vec![stale_entry],
};
write_cache(vault, &stale_cache);
write_cache(vault, &stale_cache, None).unwrap();
// Load via cached path — stale version must trigger full rescan
let entries = scan_vault_cached(vault).unwrap();
@@ -1034,4 +1344,79 @@ mod tests {
"committed .yml file must be picked up by incremental cache update"
);
}
#[test]
fn test_load_cache_marks_invalid_json() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
fs::write(cache_path(vault), "{ not-json").unwrap();
let load = load_cache(vault);
assert!(
matches!(load, CacheLoadState::Invalid(_)),
"invalid cache JSON must be distinguished from a cache miss"
);
}
#[test]
fn test_write_cache_skips_overwriting_newer_cache() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
let original = VaultCache {
version: CACHE_VERSION,
vault_path: vault.to_string_lossy().to_string(),
commit_hash: "original".to_string(),
entries: vec![],
};
write_cache(vault, &original, None).unwrap();
let CacheLoadState::Loaded(loaded) = load_cache(vault) else {
panic!("expected original cache to load");
};
let newer = VaultCache {
commit_hash: "newer".to_string(),
..original
};
write_cache(vault, &newer, Some(loaded.fingerprint.clone())).unwrap();
let stale = VaultCache {
commit_hash: "stale".to_string(),
..newer
};
let outcome = write_cache(vault, &stale, Some(loaded.fingerprint)).unwrap();
assert_eq!(outcome, CacheWriteOutcome::SkippedConcurrentUpdate);
let CacheLoadState::Loaded(final_cache) = load_cache(vault) else {
panic!("expected final cache to load");
};
assert_eq!(final_cache.cache.commit_hash, "newer");
}
#[test]
fn test_write_cache_skips_when_writer_lock_is_held() {
let (_lock, _cache_tmp, dir) = setup_git_vault();
let vault = dir.path();
let lock_path = cache_lock_path(vault);
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&lock_path, "busy").unwrap();
let cache = VaultCache {
version: CACHE_VERSION,
vault_path: vault.to_string_lossy().to_string(),
commit_hash: "busy".to_string(),
entries: vec![],
};
let outcome = write_cache(vault, &cache, None).unwrap();
assert_eq!(outcome, CacheWriteOutcome::SkippedActiveWriter);
assert!(
!cache_path(vault).exists(),
"active writer lock must prevent a competing cache write"
);
}
}

View File

@@ -1,4 +1,5 @@
use std::fs;
use std::io::{Error, ErrorKind, Write};
use std::path::Path;
use std::time::UNIX_EPOCH;
@@ -20,6 +21,53 @@ pub(crate) fn read_file_metadata(path: &Path) -> Result<(Option<u64>, Option<u64
Ok((modified_at, created_at, metadata.len()))
}
fn invalid_utf8_text_error(path: &Path) -> String {
format!("File is not valid UTF-8 text: {}", path.display())
}
fn is_invalid_platform_path_error(error: &Error) -> bool {
error.kind() == ErrorKind::InvalidInput || error.raw_os_error() == Some(123)
}
#[derive(Clone, Copy)]
enum NoteIoOperation {
Save,
Create,
}
#[derive(Clone, Copy)]
struct NotePathDisplay<'a> {
value: &'a str,
}
impl<'a> NotePathDisplay<'a> {
fn new(value: &'a str) -> Self {
Self { value }
}
}
impl NoteIoOperation {
fn verb(self) -> &'static str {
match self {
Self::Save => "save",
Self::Create => "create",
}
}
}
fn note_io_error(operation: NoteIoOperation, path: NotePathDisplay<'_>, error: &Error) -> String {
let verb = operation.verb();
if is_invalid_platform_path_error(error) {
let path = path.value;
format!(
"Failed to {verb} note: the path is invalid on this platform. Rename the note or move it to a valid folder, then try again. Path: {path}"
)
} else {
let path = path.value;
format!("Failed to {verb} {path}: {error}")
}
}
/// Read the content of a single note file.
pub fn get_note_content(path: &Path) -> Result<String, String> {
if !path.exists() {
@@ -28,7 +76,8 @@ pub fn get_note_content(path: &Path) -> Result<String, String> {
if !path.is_file() {
return Err(format!("Path is not a file: {}", path.display()));
}
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))
let bytes = fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
String::from_utf8(bytes).map_err(|_| invalid_utf8_text_error(path))
}
fn validate_save_path(file_path: &Path, display_path: &str) -> Result<(), String> {
@@ -56,10 +105,54 @@ pub fn save_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
fs::write(file_path, content).map_err(|e| format!("Failed to save {}: {}", path, e))
fs::write(file_path, content)
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
/// Create a new note file without overwriting any existing file.
pub fn create_note_content(path: &str, content: &str) -> Result<(), String> {
let file_path = Path::new(path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| {
note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e)
})?;
}
}
validate_save_path(file_path, path)?;
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(file_path)
.map_err(|e| match e.kind() {
ErrorKind::AlreadyExists => format!("File already exists: {}", path),
_ => note_io_error(NoteIoOperation::Create, NotePathDisplay::new(path), &e),
})?;
file.write_all(content.as_bytes())
.map_err(|e| note_io_error(NoteIoOperation::Save, NotePathDisplay::new(path), &e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_windows_invalid_path_syntax_as_recoverable_save_error() {
let path = r"C:\Users\@raflymln\notes\untitled-note-1777236475.md";
let message = note_io_error(
NoteIoOperation::Save,
NotePathDisplay::new(path),
&Error::from_raw_os_error(123),
);
assert!(message.contains("path is invalid on this platform"));
assert!(message.contains("Rename the note or move it to a valid folder"));
assert!(!message.contains("os error 123"));
}
}

View File

@@ -0,0 +1,101 @@
const WINDOWS_RESERVED_DEVICE_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
pub(crate) fn validate_filename_stem(stem: &str) -> Result<(), String> {
validate_portable_name_segment(stem, "Invalid filename")
}
pub(crate) fn validate_folder_name(name: &str) -> Result<(), String> {
validate_portable_name_segment(name, "Invalid folder name")
}
pub(crate) fn validate_view_filename_stem(stem: &str) -> Result<(), String> {
validate_portable_name_segment(stem, "Invalid view filename")
}
fn validate_portable_name_segment(value: &str, message: &str) -> Result<(), String> {
if is_invalid_portable_name_segment(value) {
return Err(message.to_string());
}
Ok(())
}
fn is_invalid_portable_name_segment(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.is_empty() || matches!(trimmed, "." | "..") {
return true;
}
if trimmed.ends_with('.') || trimmed.ends_with(' ') {
return true;
}
if trimmed.chars().any(is_invalid_portable_name_char) {
return true;
}
is_windows_reserved_device_name(trimmed)
}
fn is_invalid_portable_name_char(ch: char) -> bool {
ch.is_control() || matches!(ch, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
}
fn is_windows_reserved_device_name(value: &str) -> bool {
let candidate = value
.split('.')
.next()
.unwrap_or(value)
.to_ascii_uppercase();
WINDOWS_RESERVED_DEVICE_NAMES
.iter()
.any(|reserved| candidate == *reserved)
}
#[cfg(test)]
mod tests {
use super::{validate_filename_stem, validate_folder_name, validate_view_filename_stem};
#[test]
fn accepts_portable_names() {
assert_eq!(validate_filename_stem("meeting-notes"), Ok(()));
assert_eq!(validate_filename_stem("draft.v2"), Ok(()));
assert_eq!(validate_folder_name("Projects"), Ok(()));
assert_eq!(validate_view_filename_stem("active-projects"), Ok(()));
}
#[test]
fn rejects_reserved_windows_device_names() {
assert_eq!(
validate_filename_stem("con"),
Err("Invalid filename".to_string())
);
assert_eq!(
validate_folder_name("Lpt1"),
Err("Invalid folder name".to_string())
);
assert_eq!(
validate_view_filename_stem("aux"),
Err("Invalid view filename".to_string())
);
}
#[test]
fn rejects_windows_invalid_characters_and_suffixes() {
assert_eq!(
validate_filename_stem("quarterly:plan"),
Err("Invalid filename".to_string())
);
assert_eq!(
validate_folder_name("Research?"),
Err("Invalid folder name".to_string())
);
assert_eq!(
validate_view_filename_stem("overview. "),
Err("Invalid view filename".to_string())
);
}
}

View File

@@ -0,0 +1,193 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Component, Path, PathBuf};
use super::filename_rules::validate_folder_name;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FolderRenameResult {
pub old_path: String,
pub new_path: String,
}
fn normalize_folder_name(next_name: &str) -> Result<String, String> {
let trimmed = next_name.trim();
if trimmed.is_empty() {
return Err("Folder name cannot be empty".to_string());
}
validate_folder_name(trimmed)?;
Ok(trimmed.to_string())
}
fn ensure_relative_folder_path(folder_path: &str) -> Result<PathBuf, String> {
let trimmed = folder_path.trim();
if trimmed.is_empty() {
return Err("Folder path cannot be empty".to_string());
}
let relative = Path::new(trimmed);
if relative.is_absolute() {
return Err("Folder path must be relative to the vault root".to_string());
}
if relative
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err("Folder path cannot escape the vault root".to_string());
}
Ok(relative.to_path_buf())
}
fn display_relative_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
pub fn rename_folder(
vault_path: &Path,
folder_path: &str,
next_name: &str,
) -> Result<FolderRenameResult, String> {
let relative_path = ensure_relative_folder_path(folder_path)?;
let normalized_name = normalize_folder_name(next_name)?;
let source_path = vault_path.join(&relative_path);
if !source_path.exists() {
return Err(format!("Folder does not exist: {}", folder_path));
}
if !source_path.is_dir() {
return Err(format!("Not a folder: {}", folder_path));
}
let current_name = source_path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.ok_or_else(|| "Folder path cannot target the vault root".to_string())?;
if current_name == normalized_name {
return Ok(FolderRenameResult {
old_path: display_relative_path(&relative_path),
new_path: display_relative_path(&relative_path),
});
}
let parent_relative = relative_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let destination_relative = parent_relative.join(&normalized_name);
let destination_path = vault_path.join(&destination_relative);
if destination_path.exists() {
return Err(format!(
"Folder '{}' already exists",
display_relative_path(&destination_relative)
));
}
fs::rename(&source_path, &destination_path)
.map_err(|error| format!("Failed to rename folder: {}", error))?;
Ok(FolderRenameResult {
old_path: display_relative_path(&relative_path),
new_path: display_relative_path(&destination_relative),
})
}
pub fn delete_folder(vault_path: &Path, folder_path: &str) -> Result<String, String> {
let relative_path = ensure_relative_folder_path(folder_path)?;
let target_path = vault_path.join(&relative_path);
if !target_path.exists() {
return Err(format!("Folder does not exist: {}", folder_path));
}
if !target_path.is_dir() {
return Err(format!("Not a folder: {}", folder_path));
}
fs::remove_dir_all(&target_path)
.map_err(|error| format!("Failed to delete folder: {}", error))?;
Ok(display_relative_path(&relative_path))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_folder(dir: &TempDir, relative: &str) -> PathBuf {
let path = dir.path().join(relative);
fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn rename_folder_updates_relative_destination() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects/laputa");
let result = rename_folder(dir.path(), "projects", "work").unwrap();
assert_eq!(
result,
FolderRenameResult {
old_path: "projects".to_string(),
new_path: "work".to_string(),
}
);
assert!(dir.path().join("work/laputa").is_dir());
assert!(!dir.path().join("projects").exists());
}
#[test]
fn rename_folder_rejects_duplicate_sibling() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
make_folder(&dir, "areas");
let error = rename_folder(dir.path(), "projects", "areas").unwrap_err();
assert_eq!(error, "Folder 'areas' already exists");
}
#[test]
fn rename_folder_rejects_invalid_names() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
let error = rename_folder(dir.path(), "projects", "../areas").unwrap_err();
assert_eq!(error, "Invalid folder name");
}
#[test]
fn rename_folder_rejects_windows_invalid_names() {
let dir = TempDir::new().unwrap();
make_folder(&dir, "projects");
let error = rename_folder(dir.path(), "projects", "LPT1").unwrap_err();
assert_eq!(error, "Invalid folder name");
}
#[test]
fn delete_folder_removes_nested_contents() {
let dir = TempDir::new().unwrap();
let nested = make_folder(&dir, "projects/laputa");
fs::write(nested.join("note.md"), "# Note\n").unwrap();
let deleted_path = delete_folder(dir.path(), "projects").unwrap();
assert_eq!(deleted_path, "projects");
assert!(!dir.path().join("projects").exists());
}
#[test]
fn delete_folder_rejects_missing_folder() {
let dir = TempDir::new().unwrap();
let error = delete_folder(dir.path(), "projects").unwrap_err();
assert_eq!(error, "Folder does not exist: projects");
}
}

View File

@@ -1,6 +1,5 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Public starter vault cloned when the user chooses Getting Started.
pub const GETTING_STARTED_REPO_URL: &str =
@@ -13,9 +12,45 @@ pub fn default_vault_path() -> Result<PathBuf, String> {
.ok_or_else(|| "Could not determine Documents directory".to_string())
}
const GETTING_STARTED_REQUIRED_CONFIG_FILES: [&str; 2] = ["type.md", "note.md"];
const GETTING_STARTED_TEMPLATE_MARKERS: [&str; 2] = ["welcome.md", "views/active-projects.yml"];
/// Check whether a vault path exists on disk.
pub fn vault_exists(path: &str) -> bool {
Path::new(path).is_dir()
let default_path = default_vault_path().ok();
vault_exists_with_default_path(Path::new(path), default_path.as_deref())
}
fn vault_exists_with_default_path(path: &Path, default_path: Option<&Path>) -> bool {
if !path.is_dir() {
return false;
}
if !is_canonical_getting_started_path(path, default_path) {
return true;
}
canonical_getting_started_vault_exists(path)
}
fn is_canonical_getting_started_path(path: &Path, default_path: Option<&Path>) -> bool {
default_path.is_some_and(|candidate| candidate == path)
}
fn canonical_getting_started_vault_exists(path: &Path) -> bool {
has_getting_started_config_files(path) && has_getting_started_template_marker(path)
}
fn has_getting_started_config_files(path: &Path) -> bool {
GETTING_STARTED_REQUIRED_CONFIG_FILES
.iter()
.all(|file| path.join(file).is_file())
}
fn has_getting_started_template_marker(path: &Path) -> bool {
GETTING_STARTED_TEMPLATE_MARKERS
.iter()
.any(|file| path.join(file).is_file())
}
/// Previous default AGENTS.md content seeded by Tolaria itself. Existing vaults
@@ -533,7 +568,7 @@ fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
return Ok(());
}
ensure_commit_identity(vault_path)?;
crate::git::ensure_author_config(vault_path)?;
crate::git::git_commit(
path_to_utf8(vault_path, "Vault path")?,
"Initialize Tolaria config files",
@@ -542,7 +577,7 @@ fn refresh_cloned_vault_config_files(vault_path: &Path) -> Result<(), String> {
}
fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
let output = Command::new("git")
let output = crate::hidden_command("git")
.args(["status", "--porcelain"])
.current_dir(vault_path)
.output()
@@ -558,38 +593,6 @@ fn vault_has_pending_changes(vault_path: &Path) -> Result<bool, String> {
))
}
fn ensure_commit_identity(vault_path: &Path) -> Result<(), String> {
for (key, fallback) in [
("user.name", "Tolaria"),
("user.email", "vault@tolaria.app"),
] {
let output = Command::new("git")
.args(["config", key])
.current_dir(vault_path)
.output()
.map_err(|e| format!("Failed to inspect git config {key}: {e}"))?;
if output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty() {
continue;
}
let set_output = Command::new("git")
.args(["config", key, fallback])
.current_dir(vault_path)
.output()
.map_err(|e| format!("Failed to set git config {key}: {e}"))?;
if !set_output.status.success() {
return Err(format!(
"git config {key} failed: {}",
String::from_utf8_lossy(&set_output.stderr).trim()
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -640,6 +643,13 @@ mod tests {
.unwrap();
}
fn write_tolaria_config_files(path: &Path) {
fs::create_dir_all(path).unwrap();
fs::write(path.join("AGENTS.md"), AGENTS_MD).unwrap();
fs::write(path.join("type.md"), "# Type\n").unwrap();
fs::write(path.join("note.md"), "# Note\n").unwrap();
}
fn assert_getting_started_vault_replaces_template(agents_content: &str) {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
@@ -669,6 +679,33 @@ mod tests {
);
}
#[test]
fn test_canonical_getting_started_path_rejects_plain_tolaria_folder() {
let dir = tempfile::TempDir::new().unwrap();
let default_path = dir.path().join("Getting Started");
write_tolaria_config_files(&default_path);
assert!(!vault_exists_with_default_path(
default_path.as_path(),
Some(default_path.as_path())
));
}
#[test]
fn test_non_canonical_vault_path_stays_permissive() {
let dir = tempfile::TempDir::new().unwrap();
let default_path = dir.path().join("Getting Started");
let other_vault_path = dir.path().join("Existing Vault");
fs::create_dir_all(&other_vault_path).unwrap();
assert!(vault_exists_with_default_path(
other_vault_path.as_path(),
Some(default_path.as_path())
));
}
#[test]
fn test_create_getting_started_vault_clones_repo() {
let dir = tempfile::TempDir::new().unwrap();
@@ -692,6 +729,22 @@ mod tests {
assert!(dest.join("note.md").exists());
}
#[test]
fn test_canonical_getting_started_path_accepts_cloned_starter_vault() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("starter");
let default_path = dir.path().join("Getting Started");
init_source_repo(&source, None);
create_getting_started_vault_from_repo(default_path.as_path(), source.to_str().unwrap())
.unwrap();
assert!(vault_exists_with_default_path(
default_path.as_path(),
Some(default_path.as_path())
));
}
#[test]
fn test_create_getting_started_vault_rejects_nonempty_destination() {
let dir = tempfile::TempDir::new().unwrap();

View File

@@ -2,12 +2,15 @@ mod cache;
mod config_seed;
mod entry;
mod file;
pub(crate) mod filename_rules;
mod folders;
mod frontmatter;
mod getting_started;
mod image;
mod migration;
mod parsing;
mod rename;
mod rename_transaction;
mod title_sync;
mod trash;
mod views;
@@ -18,13 +21,15 @@ pub use config_seed::{
seed_config_files, AiGuidanceFileState, VaultAiGuidanceStatus,
};
pub use entry::{FolderNode, VaultEntry};
pub use file::{get_note_content, save_note_content};
pub use file::{create_note_content, get_note_content, save_note_content};
pub use folders::{delete_folder, rename_folder, FolderRenameResult};
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{
auto_rename_untitled, detect_renames, rename_note, rename_note_filename,
update_wikilinks_for_renames, DetectedRename, RenameResult,
auto_rename_untitled, detect_renames, move_note_to_folder, rename_note, rename_note_filename,
update_wikilinks_for_renames, AutoRenameUntitledRequest, DetectedRename,
MoveNoteToFolderRequest, RenameNoteFilenameRequest, RenameNoteRequest, RenameResult,
};
pub use title_sync::{sync_title_on_open, SyncAction};
pub use trash::{batch_delete_notes, delete_note};
@@ -208,13 +213,19 @@ pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
}
}
/// Directories that are never shown in the folder tree or scanned for notes.
/// Directories hidden from user-facing vault scans.
const HIDDEN_DIRS: &[&str] = &[".git", ".laputa", ".DS_Store"];
/// Keep type definitions in their dedicated sidebar section instead of the generic folder tree.
const FOLDER_TREE_EXCLUDED_DIRS: &[&str] = &["type"];
fn is_hidden_dir(name: &str) -> bool {
name.starts_with('.') || HIDDEN_DIRS.contains(&name)
}
fn is_folder_tree_hidden_dir(name: &str) -> bool {
is_hidden_dir(name) || FOLDER_TREE_EXCLUDED_DIRS.contains(&name)
}
pub(crate) fn is_md_file(path: &Path) -> bool {
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
}
@@ -413,6 +424,14 @@ pub fn scan_vault(
));
}
if let Err(err) = rename::recover_pending_rename_transactions(vault_path) {
log::warn!(
"Failed to recover pending rename transactions in {}: {}",
vault_path.display(),
err
);
}
let mut entries = Vec::new();
scan_all_files(vault_path, git_dates, &mut entries);
@@ -420,8 +439,7 @@ pub fn scan_vault(
Ok(entries)
}
/// Build a tree of visible folders in the vault.
/// Excludes hidden directories (starting with `.`).
/// Build a tree of user-created folders in the vault.
pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String> {
if !vault_path.is_dir() {
return Err(format!("Not a directory: {}", vault_path.display()));
@@ -438,7 +456,7 @@ pub fn scan_vault_folders(vault_path: &Path) -> Result<Vec<FolderNode>, String>
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if is_hidden_dir(&name) {
if is_folder_tree_hidden_dir(&name) {
continue;
}
let rel_path = path

View File

@@ -28,6 +28,8 @@ mod complex_frontmatter;
mod display_metadata;
#[path = "mod_tests/folder_and_file_kind.rs"]
mod folder_and_file_kind;
#[path = "mod_tests/journal_type_visibility.rs"]
mod journal_type_visibility;
#[path = "mod_tests/real_vault_consistency.rs"]
mod real_vault_consistency;
#[path = "mod_tests/relationships.rs"]

View File

@@ -33,6 +33,20 @@ fn test_scan_vault_folders_excludes_hidden() {
assert_eq!(folders[0].name, "visible");
}
#[test]
fn test_scan_vault_folders_keeps_default_vault_folders_visible() {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("attachments")).unwrap();
std::fs::create_dir_all(dir.path().join("type")).unwrap();
std::fs::create_dir_all(dir.path().join("views")).unwrap();
std::fs::create_dir_all(dir.path().join("projects")).unwrap();
let folders = scan_vault_folders(dir.path()).unwrap();
let names: Vec<&str> = folders.iter().map(|folder| folder.name.as_str()).collect();
assert_eq!(names, vec!["attachments", "projects", "views"]);
}
#[test]
fn test_scan_vault_folders_flat_vault() {
let dir = TempDir::new().unwrap();

View File

@@ -0,0 +1,38 @@
use super::*;
use std::collections::HashMap;
#[test]
fn test_scan_vault_preserves_explicit_journal_type_definition() {
let dir = TempDir::new().unwrap();
create_test_file(
dir.path(),
"journal.md",
"---\ntype: Type\nvisible: true\n---\n# Journal\n",
);
create_test_file(
dir.path(),
"2026-03-11.md",
"---\ntitle: March 11\ntype: Journal\n---\n# March 11\n",
);
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
assert_eq!(entries.len(), 2);
let journal_type = entries
.iter()
.find(|entry| entry.filename == "journal.md")
.expect("expected the explicit Journal type file to be scanned");
assert_eq!(journal_type.title, "Journal");
assert_eq!(journal_type.is_a.as_deref(), Some("Type"));
assert_eq!(journal_type.visible, Some(true));
let journal_note = entries
.iter()
.find(|entry| entry.filename == "2026-03-11.md")
.expect("expected the Journal note to be scanned");
assert_eq!(journal_note.is_a.as_deref(), Some("Journal"));
assert_eq!(
journal_note.relationships.get("Type"),
Some(&vec!["[[journal]]".to_string()]),
);
}

View File

@@ -120,3 +120,17 @@ fn test_get_note_content_nonexistent() {
let result = get_note_content(Path::new("/nonexistent/path/file.md"));
assert!(result.is_err());
}
#[test]
fn test_get_note_content_invalid_utf8() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("invalid.csv");
std::fs::write(&path, [0x66, 0x6f, 0x80]).unwrap();
let result = get_note_content(&path);
assert_eq!(
result.unwrap_err(),
format!("File is not valid UTF-8 text: {}", path.display())
);
}

View File

@@ -165,3 +165,29 @@ fn test_save_note_content_deeply_nested_new_directory() {
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_create_note_content_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("new-type/briefing.md");
let content = "---\ntitle: Briefing\ntype: Note\n---\n";
assert!(!path.parent().unwrap().exists());
create_note_content(path.to_str().unwrap(), content).unwrap();
assert!(path.exists());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
}
#[test]
fn test_create_note_content_rejects_existing_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("briefing.md");
fs::write(&path, "# Existing\n").unwrap();
let err = create_note_content(path.to_str().unwrap(), "# Replacement\n")
.expect_err("expected create-only write to reject collisions");
assert!(err.contains("already exists"));
assert_eq!(fs::read_to_string(&path).unwrap(), "# Existing\n");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,287 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
struct RenameTransaction {
old_path: String,
new_path: String,
backup_path: String,
}
pub(super) struct RenameWorkspace {
dir: PathBuf,
}
impl RenameWorkspace {
pub(super) fn new(vault: &Path) -> Result<Self, String> {
let dir = vault.join(".tolaria-rename-txn");
fs::create_dir_all(&dir).map_err(|e| {
format!(
"Failed to create rename transaction dir {}: {}",
dir.display(),
e
)
})?;
Ok(Self { dir })
}
pub(super) fn stage_note_content(&self, content: &str) -> Result<NamedTempFile, String> {
let mut staged = NamedTempFile::new_in(&self.dir)
.map_err(|e| format!("Failed to create staged rename file: {}", e))?;
staged
.write_all(content.as_bytes())
.map_err(|e| format!("Failed to write staged rename file: {}", e))?;
staged
.as_file_mut()
.sync_all()
.map_err(|e| format!("Failed to sync staged rename file: {}", e))?;
Ok(staged)
}
pub(super) fn operation<'a>(
&self,
old_path: &'a str,
old_file: &'a Path,
) -> RenameOperation<'a> {
RenameOperation {
old_path,
old_file,
backup_path: self.dir.join(format!("{}.bak", Uuid::new_v4())),
manifest_path: self.dir.join(format!("{}.json", Uuid::new_v4())),
}
}
}
pub(super) struct CommittedRename {
new_file: PathBuf,
manifest_path: PathBuf,
backup_path: PathBuf,
}
impl CommittedRename {
pub(super) fn new_file(&self) -> &Path {
&self.new_file
}
}
impl Drop for CommittedRename {
fn drop(&mut self) {
let _ = fs::remove_file(&self.backup_path);
let _ = fs::remove_file(&self.manifest_path);
}
}
pub(super) struct RenameOperation<'a> {
old_path: &'a str,
old_file: &'a Path,
backup_path: PathBuf,
manifest_path: PathBuf,
}
impl<'a> RenameOperation<'a> {
pub(super) fn rename_with_candidates(
&self,
staged: NamedTempFile,
desired_filename: &str,
parent_dir: &Path,
) -> Result<CommittedRename, String> {
let mut staged = staged;
for attempt in 0.. {
let candidate = parent_dir.join(candidate_filename(desired_filename, attempt));
self.prepare(&candidate)?;
match staged.persist_noclobber(&candidate) {
Ok(_) => return Ok(self.committed(candidate)),
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
staged = err.file;
self.rollback()?;
}
Err(err) => {
self.rollback()?;
return Err(format!(
"Failed to create {}: {}",
candidate.display(),
err.error
));
}
}
}
unreachable!()
}
pub(super) fn rename_exact(
&self,
staged: NamedTempFile,
new_file: &Path,
) -> Result<CommittedRename, String> {
self.prepare(new_file)?;
match staged.persist_noclobber(new_file) {
Ok(_) => Ok(self.committed(new_file.to_path_buf())),
Err(err) if err.error.kind() == ErrorKind::AlreadyExists => {
self.rollback()?;
Err("A note with that name already exists".to_string())
}
Err(err) => {
self.rollback()?;
Err(format!(
"Failed to rename {} to {}: {}",
self.old_path,
new_file.to_string_lossy(),
err.error
))
}
}
}
fn prepare(&self, new_file: &Path) -> Result<(), String> {
self.write_manifest(new_file)?;
self.move_into_backup()
}
fn write_manifest(&self, new_file: &Path) -> Result<(), String> {
let transaction = RenameTransaction {
old_path: self.old_path.to_string(),
new_path: new_file.to_string_lossy().to_string(),
backup_path: self.backup_path.to_string_lossy().to_string(),
};
let data = serde_json::to_string(&transaction)
.map_err(|e| format!("Failed to serialize rename transaction: {}", e))?;
fs::write(&self.manifest_path, data).map_err(|e| {
format!(
"Failed to write rename transaction {}: {}",
self.manifest_path.display(),
e
)
})
}
fn move_into_backup(&self) -> Result<(), String> {
fs::rename(self.old_file, &self.backup_path).map_err(|e| {
format!(
"Failed to move {} into rename backup {}: {}",
self.old_file.display(),
self.backup_path.display(),
e
)
})
}
fn rollback(&self) -> Result<(), String> {
if self.backup_path.exists() {
if let Some(parent) = self.old_file.parent() {
let _ = fs::create_dir_all(parent);
}
fs::rename(&self.backup_path, self.old_file).map_err(|e| {
format!(
"Failed to restore {} from {}: {}",
self.old_file.display(),
self.backup_path.display(),
e
)
})?;
}
let _ = fs::remove_file(&self.manifest_path);
Ok(())
}
fn committed(&self, new_file: PathBuf) -> CommittedRename {
CommittedRename {
new_file,
manifest_path: self.manifest_path.clone(),
backup_path: self.backup_path.clone(),
}
}
}
fn candidate_filename(filename: &str, attempt: usize) -> String {
let stem = Path::new(filename)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(filename)
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
.unwrap_or_default();
if attempt == 0 {
return filename.to_string();
}
format!("{}-{}{}", stem, attempt + 1, ext)
}
fn transaction_dir(vault: &Path) -> PathBuf {
vault.join(".tolaria-rename-txn")
}
pub(super) fn recover_pending_rename_transactions(vault: &Path) -> Result<(), String> {
let txn_dir = transaction_dir(vault);
if !txn_dir.exists() {
return Ok(());
}
let entries = fs::read_dir(&txn_dir).map_err(|e| {
format!(
"Failed to read rename transaction dir {}: {}",
txn_dir.display(),
e
)
})?;
for entry in entries {
let path = entry
.map_err(|e| format!("Failed to read rename transaction entry: {}", e))?
.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Ok(data) = fs::read_to_string(&path) else {
let _ = fs::remove_file(&path);
continue;
};
let Ok(transaction) = serde_json::from_str::<RenameTransaction>(&data) else {
let _ = fs::remove_file(&path);
continue;
};
recover_rename_transaction(&path, transaction)?;
}
Ok(())
}
fn recover_rename_transaction(
manifest_path: &Path,
transaction: RenameTransaction,
) -> Result<(), String> {
let old_path = Path::new(&transaction.old_path);
let new_path = Path::new(&transaction.new_path);
let backup_path = Path::new(&transaction.backup_path);
if !backup_path.exists() {
let _ = fs::remove_file(manifest_path);
return Ok(());
}
if new_path.exists() || old_path.exists() {
let _ = fs::remove_file(backup_path);
let _ = fs::remove_file(manifest_path);
return Ok(());
}
if let Some(parent) = old_path.parent() {
let _ = fs::create_dir_all(parent);
}
fs::rename(backup_path, old_path).map_err(|e| {
format!(
"Failed to recover {} from {}: {}",
old_path.display(),
backup_path.display(),
e
)
})?;
let _ = fs::remove_file(manifest_path);
Ok(())
}

View File

@@ -0,0 +1,514 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{
App, AppHandle, LogicalPosition, LogicalSize, Manager, Position, RunEvent, Size, WebviewWindow,
WindowEvent,
};
const MAIN_WINDOW_LABEL: &str = "main";
const WINDOW_STATE_FILE: &str = "window-state.json";
const MIN_WINDOW_WIDTH: u32 = 480;
const MIN_WINDOW_HEIGHT: u32 = 400;
#[derive(Debug, Default)]
pub(crate) struct MainWindowFrameState(Mutex<Option<WindowFrame>>);
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
struct WindowFrame {
x: i32,
y: i32,
width: u32,
height: u32,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
struct ScreenArea {
x: i32,
y: i32,
width: u32,
height: u32,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PersistedWindowState {
main: Option<WindowFrame>,
#[serde(default)]
coordinate_space: WindowFrameCoordinateSpace,
}
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum WindowFrameCoordinateSpace {
#[default]
Physical,
Logical,
}
pub(crate) fn restore_main_window_state(app: &mut App) {
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
restore_main_window_frame(app.handle(), &window, "during setup");
}
pub(crate) fn handle_run_event(app_handle: &AppHandle, event: &RunEvent) {
match event {
event if restores_window_frame_after_runtime_ready(event) => {
restore_main_window_state_from_handle(app_handle)
}
RunEvent::WindowEvent {
label,
event:
WindowEvent::Moved(_) | WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. },
..
} if label == MAIN_WINDOW_LABEL => cache_current_normal_frame(app_handle),
RunEvent::WindowEvent {
label,
event: WindowEvent::CloseRequested { .. } | WindowEvent::Destroyed,
..
} if label == MAIN_WINDOW_LABEL => save_main_window_frame(app_handle),
RunEvent::Exit => save_main_window_frame(app_handle),
_ => {}
}
}
fn restores_window_frame_after_runtime_ready(event: &RunEvent) -> bool {
matches!(event, RunEvent::Ready)
}
fn restore_main_window_state_from_handle(app_handle: &AppHandle) {
let Some(window) = app_handle.get_webview_window(MAIN_WINDOW_LABEL) else {
return;
};
restore_main_window_frame(app_handle, &window, "after runtime ready");
}
fn restore_main_window_frame(app_handle: &AppHandle, window: &WebviewWindow, phase: &str) {
let Some(frame) = read_main_window_frame(window_scale_factor(window)) else {
return;
};
let areas = current_screen_areas(window);
let Some(restored_frame) = fit_frame_to_screens(frame, &areas) else {
return;
};
if let Err(err) = apply_window_frame(window, restored_frame) {
log::warn!("Failed to restore main window state {phase}: {err}");
return;
}
cache_frame(app_handle, restored_frame);
}
fn cache_current_normal_frame(app_handle: &AppHandle) {
if let Some(frame) = current_normal_main_window_frame(app_handle) {
cache_frame(app_handle, frame);
}
}
fn save_main_window_frame(app_handle: &AppHandle) {
let frame = current_normal_main_window_frame(app_handle).or_else(|| cached_frame(app_handle));
if let Some(frame) = frame {
if let Err(err) = write_main_window_frame(frame) {
log::warn!("Failed to save main window state: {err}");
}
}
}
fn current_normal_main_window_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
let window = app_handle.get_webview_window(MAIN_WINDOW_LABEL)?;
if !is_normal_window(&window) {
return None;
}
read_window_frame(&window).filter(is_valid_saved_frame)
}
fn is_normal_window(window: &WebviewWindow) -> bool {
let is_fullscreen = window.is_fullscreen().unwrap_or(false);
let is_maximized = window.is_maximized().unwrap_or(false);
let is_minimized = window.is_minimized().unwrap_or(false);
!is_fullscreen && !is_maximized && !is_minimized
}
fn read_window_frame(window: &WebviewWindow) -> Option<WindowFrame> {
let scale_factor = window_scale_factor(window);
let position = window.outer_position().ok()?;
let size = window.inner_size().ok()?;
Some(WindowFrame::from_logical_geometry(
position.to_logical::<f64>(scale_factor),
size.to_logical::<f64>(scale_factor),
))
}
fn apply_window_frame(window: &WebviewWindow, frame: WindowFrame) -> tauri::Result<()> {
window.set_size(Size::Logical(LogicalSize::new(
frame.width as f64,
frame.height as f64,
)))?;
window.set_position(Position::Logical(LogicalPosition::new(
frame.x as f64,
frame.y as f64,
)))
}
fn current_screen_areas(window: &WebviewWindow) -> Vec<ScreenArea> {
let scale_factor = window_scale_factor(window);
window
.available_monitors()
.unwrap_or_default()
.into_iter()
.map(|monitor| {
let area = monitor.work_area();
let position = area.position.to_logical::<f64>(scale_factor);
let size = area.size.to_logical::<f64>(scale_factor);
ScreenArea {
x: rounded_i32(position.x),
y: rounded_i32(position.y),
width: rounded_u32(size.width),
height: rounded_u32(size.height),
}
})
.filter(ScreenArea::has_area)
.collect()
}
fn window_scale_factor(window: &WebviewWindow) -> f64 {
window.scale_factor().unwrap_or(1.0).max(1.0)
}
fn fit_frame_to_screens(frame: WindowFrame, screens: &[ScreenArea]) -> Option<WindowFrame> {
if frame_is_visible(frame, screens) {
return Some(frame);
}
let screen = best_screen_for_frame(frame, screens)?;
let width = clamp_dimension(frame.width, MIN_WINDOW_WIDTH, screen.width);
let height = clamp_dimension(frame.height, MIN_WINDOW_HEIGHT, screen.height);
Some(WindowFrame {
x: clamp_axis(frame.x, width, screen.x, screen.width),
y: clamp_axis(frame.y, height, screen.y, screen.height),
width,
height,
})
}
fn frame_is_visible(frame: WindowFrame, screens: &[ScreenArea]) -> bool {
frame_corners(frame)
.into_iter()
.all(|point| screens.iter().any(|screen| screen.contains(point)))
}
fn frame_corners(frame: WindowFrame) -> [(i32, i32); 4] {
let right = frame.right() - 1;
let bottom = frame.bottom() - 1;
[
(frame.x, frame.y),
(right, frame.y),
(frame.x, bottom),
(right, bottom),
]
}
fn best_screen_for_frame(frame: WindowFrame, screens: &[ScreenArea]) -> Option<ScreenArea> {
screens
.iter()
.copied()
.filter(ScreenArea::has_area)
.max_by_key(|screen| intersection_area(frame, *screen))
}
fn intersection_area(frame: WindowFrame, screen: ScreenArea) -> u64 {
let left = frame.x.max(screen.x);
let top = frame.y.max(screen.y);
let right = frame.right().min(screen.right());
let bottom = frame.bottom().min(screen.bottom());
if right <= left || bottom <= top {
return 0;
}
(right - left) as u64 * (bottom - top) as u64
}
fn clamp_dimension(value: u32, min: u32, max: u32) -> u32 {
if max < min {
max
} else {
value.clamp(min, max)
}
}
fn clamp_axis(value: i32, size: u32, area_start: i32, area_size: u32) -> i32 {
let max_start = area_start + area_size as i32 - size as i32;
if max_start < area_start {
return area_start;
}
value.clamp(area_start, max_start)
}
fn cache_frame(app_handle: &AppHandle, frame: WindowFrame) {
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
if let Ok(mut cached_frame) = state.0.lock() {
*cached_frame = Some(frame);
};
}
fn cached_frame(app_handle: &AppHandle) -> Option<WindowFrame> {
let state: tauri::State<'_, MainWindowFrameState> = app_handle.state();
state.0.lock().ok().and_then(|cached_frame| *cached_frame)
}
fn window_state_path() -> Result<PathBuf, String> {
crate::settings::preferred_app_config_path(WINDOW_STATE_FILE)
}
fn read_main_window_frame(scale_factor: f64) -> Option<WindowFrame> {
let content = fs::read_to_string(window_state_path().ok()?).ok()?;
let persisted: PersistedWindowState = serde_json::from_str(&content).ok()?;
persisted
.main
.map(|frame| {
persisted
.coordinate_space
.to_logical_frame(frame, scale_factor)
})
.filter(is_valid_saved_frame)
}
fn write_main_window_frame(frame: WindowFrame) -> Result<(), String> {
let path = window_state_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create window state directory: {e}"))?;
}
let persisted = PersistedWindowState {
main: Some(frame),
coordinate_space: WindowFrameCoordinateSpace::Logical,
};
let json = serde_json::to_string_pretty(&persisted)
.map_err(|e| format!("Failed to serialize window state: {e}"))?;
fs::write(path, json).map_err(|e| format!("Failed to write window state: {e}"))
}
fn is_valid_saved_frame(frame: &WindowFrame) -> bool {
frame.width >= MIN_WINDOW_WIDTH && frame.height >= MIN_WINDOW_HEIGHT
}
fn rounded_i32(value: f64) -> i32 {
value.round() as i32
}
fn rounded_u32(value: f64) -> u32 {
value.round().max(0.0) as u32
}
impl WindowFrame {
fn from_logical_geometry(position: LogicalPosition<f64>, size: LogicalSize<f64>) -> Self {
Self {
x: rounded_i32(position.x),
y: rounded_i32(position.y),
width: rounded_u32(size.width),
height: rounded_u32(size.height),
}
}
fn to_logical(self, scale_factor: f64) -> Self {
let scale_factor = scale_factor.max(1.0);
Self {
x: rounded_i32(self.x as f64 / scale_factor),
y: rounded_i32(self.y as f64 / scale_factor),
width: rounded_u32(self.width as f64 / scale_factor),
height: rounded_u32(self.height as f64 / scale_factor),
}
}
fn right(self) -> i32 {
self.x + self.width as i32
}
fn bottom(self) -> i32 {
self.y + self.height as i32
}
}
impl WindowFrameCoordinateSpace {
fn to_logical_frame(self, frame: WindowFrame, scale_factor: f64) -> WindowFrame {
match self {
Self::Logical => frame,
Self::Physical => frame.to_logical(scale_factor),
}
}
}
impl ScreenArea {
fn right(self) -> i32 {
self.x + self.width as i32
}
fn bottom(self) -> i32 {
self.y + self.height as i32
}
fn has_area(&self) -> bool {
self.width > 0 && self.height > 0
}
fn contains(&self, point: (i32, i32)) -> bool {
let (x, y) = point;
x >= self.x && x < self.right() && y >= self.y && y < self.bottom()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(x: i32, y: i32, width: u32, height: u32) -> WindowFrame {
WindowFrame {
x,
y,
width,
height,
}
}
fn screen(x: i32, y: i32, width: u32, height: u32) -> ScreenArea {
ScreenArea {
x,
y,
width,
height,
}
}
#[test]
fn records_logical_window_geometry_for_persistence() {
let saved = WindowFrame::from_logical_geometry(
LogicalPosition::new(80.0, 120.0),
LogicalSize::new(1100.0, 700.0),
);
assert_eq!(saved, frame(80, 120, 1100, 700));
}
#[test]
fn migrates_legacy_physical_frames_to_logical_points() {
let saved = frame(160, 240, 2200, 1400);
assert_eq!(
WindowFrameCoordinateSpace::Physical.to_logical_frame(saved, 2.0),
frame(80, 120, 1100, 700)
);
}
#[test]
fn keeps_explicit_logical_frames_unscaled() {
let saved = frame(80, 120, 1100, 700);
assert_eq!(
WindowFrameCoordinateSpace::Logical.to_logical_frame(saved, 2.0),
saved
);
}
#[test]
fn keeps_valid_frame_unchanged() {
let saved = frame(120, 80, 1400, 900);
let screens = [screen(0, 0, 1920, 1080)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn clamps_oversized_frame_to_current_work_area() {
let saved = frame(-100, -80, 2600, 1800);
let screens = [screen(0, 0, 1440, 900)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(0, 0, 1440, 900))
);
}
#[test]
fn moves_offscreen_frame_back_to_a_visible_screen() {
let saved = frame(3200, 1800, 900, 700);
let screens = [screen(0, 0, 1440, 900)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(540, 200, 900, 700))
);
}
#[test]
fn picks_the_screen_with_the_largest_visible_overlap() {
let saved = frame(1700, 100, 900, 700);
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn ignores_empty_screen_areas_when_restoring() {
let saved = frame(100, 100, 800, 600);
let screens = [screen(0, 0, 0, 900), screen(0, 0, 1440, 900)];
assert_eq!(fit_frame_to_screens(saved, &screens), Some(saved));
}
#[test]
fn returns_none_when_no_usable_screens_exist() {
let saved = frame(100, 100, 800, 600);
assert_eq!(fit_frame_to_screens(saved, &[]), None);
assert_eq!(fit_frame_to_screens(saved, &[screen(0, 0, 0, 0)]), None);
}
#[test]
fn fits_to_tiny_work_area_when_it_is_smaller_than_minimum_size() {
let saved = frame(100, 100, 800, 600);
let screens = [screen(0, 0, 320, 240)];
assert_eq!(
fit_frame_to_screens(saved, &screens),
Some(frame(0, 0, 320, 240))
);
}
#[test]
fn reports_visibility_across_adjacent_screens() {
let screens = [screen(0, 0, 1920, 1080), screen(1920, 0, 1440, 900)];
assert!(frame_is_visible(frame(1700, 100, 900, 700), &screens));
assert!(!frame_is_visible(frame(1700, 850, 900, 300), &screens));
}
#[test]
fn computes_frame_and_screen_edges_for_overlap_checks() {
let saved = frame(10, 20, 800, 600);
let area = screen(0, 0, 500, 400);
assert_eq!(saved.right(), 810);
assert_eq!(saved.bottom(), 620);
assert_eq!(area.right(), 500);
assert_eq!(area.bottom(), 400);
assert_eq!(intersection_area(saved, area), 490 * 380);
assert_eq!(intersection_area(saved, screen(900, 900, 200, 200)), 0);
}
#[test]
fn rejects_corrupted_tiny_saved_frames() {
assert!(!is_valid_saved_frame(&frame(100, 100, 1, 900)));
assert!(!is_valid_saved_frame(&frame(100, 100, 1400, 1)));
}
#[test]
fn restores_again_after_runtime_ready() {
assert!(restores_window_frame_after_runtime_ready(&RunEvent::Ready));
assert!(!restores_window_frame_after_runtime_ready(
&RunEvent::Resumed
));
}
}

View File

@@ -23,16 +23,25 @@
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"backgroundColor": "#F7F6F3",
"dragDropEnabled": false
"dragDropEnabled": true
}
],
"security": {
"csp": null,
"csp": {
"default-src": "'self' ipc: http://ipc.localhost",
"script-src": "'self' https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com",
"connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
"img-src": "'self' asset: http://asset.localhost data: blob: https:",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
"style-src-elem": "'self' 'nonce-tolaria-runtime-style' https://fonts.googleapis.com",
"style-src-attr": "'unsafe-inline'",
"font-src": "'self' data: https://fonts.gstatic.com",
"media-src": "'self' data: blob: https:",
"object-src": "'none'"
},
"assetProtocol": {
"enable": true,
"scope": [
"**"
]
"scope": []
}
}
},
@@ -40,6 +49,11 @@
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
}
},
"resources": {
"resources/mcp-server/**/*": "mcp-server/"
},
@@ -56,6 +70,9 @@
"endpoints": [
"https://refactoringhq.github.io/tolaria/stable/latest.json"
],
"windows": {
"installMode": "passive"
},
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
}
}

View File

@@ -68,9 +68,9 @@
/* AI highlight animation — applied by useAiActivity when MCP calls ui_highlight */
@keyframes ai-highlight-glow {
0% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.8); }
50% { box-shadow: inset 0 0 8px 2px rgba(99, 102, 241, 0.4); }
100% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0); }
0% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 80%, transparent); }
50% { box-shadow: inset 0 0 8px 2px color-mix(in srgb, var(--accent-blue) 40%, transparent); }
100% { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-blue) 0%, transparent); }
}
.ai-highlight {

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