Compare commits

..

659 Commits

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

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

Three additive changes:

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:35:15 +02:00
github-actions[bot]
fe939763cf Merge branch 'main' into pr-728 2026-05-25 15:18:46 +00:00
github-actions[bot]
e15aa76826 Merge branch 'main' into pr-736 2026-05-25 15:18:44 +00:00
github-actions[bot]
ebe85faf53 Merge branch 'main' into pr-735 2026-05-25 15:18:39 +00:00
github-actions[bot]
d0ce5ae165 Merge branch 'main' into main 2026-05-25 15:18:35 +00:00
lucaronin
780e33bff2 fix: recover rich editor keydown join errors 2026-05-25 17:09:11 +02:00
github-actions[bot]
443d34b8b2 Merge branch 'main' into pr-728 2026-05-25 10:22:31 +00:00
github-actions[bot]
77fa49d5f5 Merge branch 'main' into pr-736 2026-05-25 10:22:29 +00:00
github-actions[bot]
a2094b57b7 Merge branch 'main' into pr-735 2026-05-25 10:22:25 +00:00
github-actions[bot]
59d272cf24 Merge branch 'main' into main 2026-05-25 10:22:21 +00:00
lucaronin
afc2ead14c fix: restore full app note windows 2026-05-25 12:07:25 +02:00
github-actions[bot]
80fb3dc6c9 Merge branch 'main' into pr-728 2026-05-25 09:58:00 +00:00
github-actions[bot]
181906cb17 Merge branch 'main' into pr-736 2026-05-25 09:57:58 +00:00
github-actions[bot]
1b00eaf461 Merge branch 'main' into pr-735 2026-05-25 09:57:54 +00:00
github-actions[bot]
9ad5ab822a Merge branch 'main' into main 2026-05-25 09:57:49 +00:00
lucaronin
c255c91a0b fix: keep sort dropdown within viewport 2026-05-25 11:45:34 +02:00
github-actions[bot]
384aa22fbd Merge branch 'main' into pr-728 2026-05-25 09:24:11 +00:00
github-actions[bot]
093d331719 Merge branch 'main' into pr-736 2026-05-25 09:24:09 +00:00
github-actions[bot]
f9c5e70916 Merge branch 'main' into pr-735 2026-05-25 09:24:05 +00:00
github-actions[bot]
d08a08195c Merge branch 'main' into main 2026-05-25 09:23:59 +00:00
lucaronin
2a3589181e fix: dedupe multi-vault auto sync fetches 2026-05-25 11:09:49 +02:00
github-actions[bot]
fb02644358 Merge branch 'main' into pr-728 2026-05-25 08:56:32 +00:00
github-actions[bot]
7c2e7a8990 Merge branch 'main' into pr-736 2026-05-25 08:56:30 +00:00
github-actions[bot]
e33f1b3106 Merge branch 'main' into pr-735 2026-05-25 08:56:26 +00:00
github-actions[bot]
815827b6e2 Merge branch 'main' into main 2026-05-25 08:56:21 +00:00
lucaronin
deb6998c97 refactor: consolidate type definition lookup 2026-05-25 10:41:24 +02:00
github-actions[bot]
1427035623 Merge branch 'main' into pr-728 2026-05-25 08:03:29 +00:00
github-actions[bot]
b7301568b8 Merge branch 'main' into pr-736 2026-05-25 08:03:26 +00:00
github-actions[bot]
0bf921dad1 Merge branch 'main' into pr-735 2026-05-25 08:03:21 +00:00
github-actions[bot]
563030982b Merge branch 'main' into main 2026-05-25 08:03:15 +00:00
lucaronin
41121ebf70 docs: add release notes for v2026-05-25 2026-05-25 10:02:26 +02:00
mehmet turac
7516dfe48a fix: treat name-only git remotes as local-only 2026-05-24 22:14:05 +03:00
mehmet turac
8764b6f78e fix: respect Kiro default agent fallback 2026-05-24 21:00:37 +03:00
github-actions[bot]
094f59eb7b Merge branch 'main' into pr-728 2026-05-24 12:54:58 +00:00
github-actions[bot]
62e1ab0e26 Merge branch 'main' into pr-736 2026-05-24 12:54:56 +00:00
github-actions[bot]
0135ce3f10 Merge branch 'main' into pr-735 2026-05-24 12:54:52 +00:00
lucaronin
c752a52e28 docs: update native QA guidance 2026-05-24 14:54:26 +02:00
github-actions[bot]
ede82fc7a2 Merge branch 'main' into pr-728 2026-05-24 12:28:24 +00:00
github-actions[bot]
8c2432339a Merge branch 'main' into pr-736 2026-05-24 12:28:21 +00:00
github-actions[bot]
fbfd0f1cf3 Merge branch 'main' into pr-735 2026-05-24 12:28:18 +00:00
lucaronin
2fb14f4c35 fix: disambiguate sidebar type visibility by vault 2026-05-24 14:18:17 +02:00
github-actions[bot]
51e32dc832 Merge branch 'main' into pr-728 2026-05-24 12:11:53 +00:00
github-actions[bot]
a541c1b916 Merge branch 'main' into pr-736 2026-05-24 12:11:51 +00:00
github-actions[bot]
411c1947ae Merge branch 'main' into pr-735 2026-05-24 12:11:47 +00:00
lucaronin
5bebd1c458 fix: restore light app icon 2026-05-24 13:52:16 +02:00
github-actions[bot]
e2e9e81ee9 Merge branch 'main' into pr-728 2026-05-24 11:49:09 +00:00
github-actions[bot]
11c7e35519 Merge branch 'main' into pr-736 2026-05-24 11:49:07 +00:00
github-actions[bot]
b9bff9223e Merge branch 'main' into pr-735 2026-05-24 11:49:04 +00:00
lucaronin
d204e95460 fix: expose desktop menu in window chrome 2026-05-24 13:33:14 +02:00
github-actions[bot]
764c899b63 Merge branch 'main' into pr-728 2026-05-24 11:26:39 +00:00
github-actions[bot]
a3a9738034 Merge branch 'main' into pr-736 2026-05-24 11:26:36 +00:00
github-actions[bot]
ad28aed309 Merge branch 'main' into pr-735 2026-05-24 11:26:33 +00:00
lucaronin
b5d462b3cd fix: sync active git vaults together 2026-05-24 13:11:29 +02:00
github-actions[bot]
35e50b75fe Merge branch 'main' into pr-728 2026-05-24 10:54:12 +00:00
github-actions[bot]
018a0aff4f Merge branch 'main' into pr-736 2026-05-24 10:54:10 +00:00
github-actions[bot]
0ffacb3a83 Merge branch 'main' into pr-734 2026-05-24 10:54:08 +00:00
github-actions[bot]
46f82d201d Merge branch 'main' into pr-735 2026-05-24 10:54:06 +00:00
lucaronin
c77d2af537 fix: ignore canceled external URL opens 2026-05-24 12:44:51 +02:00
github-actions[bot]
e662730fbc Merge branch 'main' into pr-728 2026-05-24 10:39:32 +00:00
github-actions[bot]
dc19b2d002 Merge branch 'main' into pr-736 2026-05-24 10:39:30 +00:00
github-actions[bot]
bb68ba78b6 Merge branch 'main' into pr-734 2026-05-24 10:39:28 +00:00
github-actions[bot]
4b375932f4 Merge branch 'main' into pr-735 2026-05-24 10:39:25 +00:00
lucaronin
7d6ba934c6 feat: switch app icon with theme 2026-05-24 12:28:29 +02:00
github-actions[bot]
aa4de6f155 Merge branch 'main' into pr-728 2026-05-24 10:18:53 +00:00
github-actions[bot]
7b62cd5e13 Merge branch 'main' into pr-736 2026-05-24 10:18:51 +00:00
github-actions[bot]
86e414d192 Merge branch 'main' into pr-734 2026-05-24 10:18:49 +00:00
github-actions[bot]
69bf1ca27b Merge branch 'main' into pr-735 2026-05-24 10:18:47 +00:00
lucaronin
9ca05c1665 fix: stack narrow status bar footer 2026-05-24 11:04:18 +02:00
github-actions[bot]
6fd0638d14 Merge branch 'main' into pr-728 2026-05-24 09:01:13 +00:00
github-actions[bot]
c62fb2227e Merge branch 'main' into pr-736 2026-05-24 09:01:10 +00:00
github-actions[bot]
29774c4f46 Merge branch 'main' into pr-734 2026-05-24 09:01:07 +00:00
github-actions[bot]
437c15a280 Merge branch 'main' into pr-735 2026-05-24 09:01:04 +00:00
lucaronin
194cebdfe0 fix: clamp selection toolbar to viewport 2026-05-24 10:48:14 +02:00
github-actions[bot]
d7e1de65b6 Merge branch 'main' into pr-728 2026-05-24 08:44:23 +00:00
github-actions[bot]
38b336357f Merge branch 'main' into pr-736 2026-05-24 08:44:21 +00:00
github-actions[bot]
7f7272e09b Merge branch 'main' into pr-734 2026-05-24 08:44:19 +00:00
github-actions[bot]
8458a337bd Merge branch 'main' into pr-735 2026-05-24 08:44:16 +00:00
lucaronin
d8e0524613 test: cover immediate new note editing 2026-05-24 10:30:16 +02:00
github-actions[bot]
72e10d3b41 Merge branch 'main' into pr-728 2026-05-24 08:22:27 +00:00
github-actions[bot]
80e685cc33 Merge branch 'main' into pr-736 2026-05-24 08:22:25 +00:00
github-actions[bot]
d87d15982f Merge branch 'main' into pr-734 2026-05-24 08:22:23 +00:00
github-actions[bot]
b8b5515a7d Merge branch 'main' into pr-735 2026-05-24 08:22:21 +00:00
lucaronin
e4afa7cfa5 fix: ratchet codescene thresholds 2026-05-24 10:08:24 +02:00
Elias Stiffel
42c3b93886 refactor: group applyMountedChange callbacks under a single field
Code review on pr-735 flagged `applyMountedChange` for exceeding the
8-parameter limit. The three effect callbacks (`onSetDefaultWorkspace`,
`onSwitchVault`, `onUpdateWorkspaceIdentity`) are now grouped under a
single `callbacks` field. The function's destructure surface drops
from 8 to 6 fields, and the semantic split between "state + change"
and "effects" is now explicit in the type.

No behaviour change; the six existing tests pass unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:45:01 +02:00
Elias Stiffel
da3e9b9865 fix: switch active vault when unmounting it via the vault switcher
Part of the fix for #735 — top-level folder creation invisible in
multi-workspace mode. The primary route, where the create-folder flow
ignored the sidebar selection and always wrote to the legacy
`resolvedPath`, is fixed in #728 (selection-aware routing in
`handleCreateFolder` and `FolderTree`). This commit fixes the
complementary state-machine bug: the vault switcher could leave
`vaultPath` pointing at an unmounted vault, so any consumer that still
reads `vaultPath` would operate on a vault the user no longer sees.

The vault switcher tracks two related paths:
- `defaultWorkspacePath` — the workspace the user is currently "on"
  (used as the destination for new notes, the graph default, etc.).
- `vaultPath` — the active vault the switcher tracks; read by the file
  watcher, folder/view reloaders, and the active-vault fallback in the
  folder loader.

Unchecking a vault in the switcher previously re-routed only
`defaultWorkspacePath` to another mounted vault, leaving `vaultPath`
pointing at the just-unmounted vault. The two paths drifted apart and
every downstream consumer that read `vaultPath` operated on stale
state — for example the folder loader's active-vault fallback (when
enabled) would silently re-introduce the unmounted vault into the
sidebar, and the file watcher would keep listening to it.

`applyMountedChange` now also calls `onSwitchVault(nextPath)` when the
unmounted vault is the active one, mirroring the existing reroute for
`defaultWorkspacePath`. After the change `vaultPath` and mounted state
stay consistent for every consumer.

`applyMountedChange` is extracted from `VaultMenu.tsx` to a sibling
helper module (`vaultMenuMountedChange.ts`) so it can be unit-tested
directly without tripping `react-refresh/only-export-components`.

Tests:
- New `VaultMenu.applyMountedChange.test.ts` covers the four reroute
  permutations (default+active, active only, default only, neither),
  the bail-out when no alternative mounted vault exists, and the
  remount no-op.

Fixes: https://github.com/refactoringhq/tolaria/issues/735
Related: https://github.com/refactoringhq/tolaria/pull/728

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:08:57 +02:00
lucaronin
c6faf005b5 fix: prefer Windows OpenCode cmd shims 2026-05-24 00:06:53 +02:00
github-actions[bot]
5374e66a80 Merge branch 'main' into pr-728 2026-05-23 21:49:29 +00:00
github-actions[bot]
3efa44df6d Merge branch 'main' into pr-736 2026-05-23 21:49:27 +00:00
github-actions[bot]
f1c2470a6e Merge branch 'main' into pr-734 2026-05-23 21:49:25 +00:00
lucaronin
2e9f07bab4 fix: use custom chrome on Windows 2026-05-23 23:36:22 +02:00
lucaronin
39159b9b1e fix: detect installed Codex on Windows 2026-05-23 23:25:38 +02:00
github-actions[bot]
1be0c4b32f Merge branch 'main' into pr-728 2026-05-23 21:01:34 +00:00
github-actions[bot]
a9f38fb372 Merge branch 'main' into pr-736 2026-05-23 21:01:32 +00:00
github-actions[bot]
2a15ecb3e3 Merge branch 'main' into pr-734 2026-05-23 21:01:30 +00:00
lucaronin
770fdee881 fix: preserve AI session when panel closes 2026-05-23 22:39:02 +02:00
github-actions[bot]
205f3641e3 Merge branch 'main' into pr-728 2026-05-23 20:30:14 +00:00
github-actions[bot]
56e5c98a84 Merge branch 'main' into pr-736 2026-05-23 20:30:11 +00:00
github-actions[bot]
73766ca471 Merge branch 'main' into pr-734 2026-05-23 20:30:09 +00:00
lucaronin
0979c0664c fix: derive AI agent targets from registry 2026-05-23 22:09:39 +02:00
github-actions[bot]
bcbd9d3c5a Merge branch 'main' into pr-728 2026-05-23 20:08:08 +00:00
github-actions[bot]
69000f205a Merge branch 'main' into pr-736 2026-05-23 20:08:06 +00:00
github-actions[bot]
d8e280b9ee Merge branch 'main' into pr-734 2026-05-23 20:08:04 +00:00
lucaronin
ce237039c9 fix: preserve editor focus during active note refresh 2026-05-23 21:53:21 +02:00
github-actions[bot]
6a3d730f0b Merge branch 'main' into pr-728 2026-05-23 19:46:31 +00:00
github-actions[bot]
c1cd894516 Merge branch 'main' into pr-736 2026-05-23 19:46:28 +00:00
github-actions[bot]
2d04335648 Merge branch 'main' into pr-734 2026-05-23 19:46:26 +00:00
lucaronin
1c8207eeb5 refactor: split vault loader workspace tests 2026-05-23 21:26:03 +02:00
github-actions[bot]
fa9855fbf3 Merge branch 'main' into pr-728 2026-05-23 19:06:37 +00:00
github-actions[bot]
d03c780180 Merge branch 'main' into pr-736 2026-05-23 19:06:35 +00:00
github-actions[bot]
c903eb1e79 Merge branch 'main' into pr-734 2026-05-23 19:06:32 +00:00
lucaronin
35d258c0ba fix: keep active vault folder roots visible 2026-05-23 21:01:36 +02:00
github-actions[bot]
720e2a9f60 Merge branch 'main' into pr-728 2026-05-23 18:18:00 +00:00
github-actions[bot]
b8b07a0310 Merge branch 'main' into pr-736 2026-05-23 18:17:57 +00:00
github-actions[bot]
f2bbcef7f0 Merge branch 'main' into pr-734 2026-05-23 18:17:55 +00:00
lucaronin
2df076f834 fix: bound code-block clipboard copies 2026-05-23 19:59:13 +02:00
github-actions[bot]
7e119ee8f0 Merge branch 'main' into pr-728 2026-05-23 17:26:45 +00:00
github-actions[bot]
58f703e0a7 Merge branch 'main' into pr-736 2026-05-23 17:26:43 +00:00
github-actions[bot]
2172719f3e Merge branch 'main' into pr-734 2026-05-23 17:26:40 +00:00
lucaronin
49008eb79c fix: recover empty startup vault reloads 2026-05-23 19:10:10 +02:00
lucaronin
454181456d fix: insert emoji shortcode suggestions 2026-05-23 19:06:07 +02:00
lucaronin
e07dab2310 docs: record app preferences context ADR 2026-05-23 19:06:03 +02:00
github-actions[bot]
6e97f3d4d3 Merge branch 'main' into pr-728 2026-05-23 16:30:09 +00:00
github-actions[bot]
a89416e9a5 Merge branch 'main' into pr-736 2026-05-23 16:30:07 +00:00
github-actions[bot]
5b275629be Merge branch 'main' into pr-734 2026-05-23 16:30:05 +00:00
Elias Stiffel
4a45616b16 Merge remote-tracking branch 'origin/pr-728' into pr-728 2026-05-23 18:17:22 +02:00
lucaronin
ae529f64cd fix: persist property-only frontmatter edits 2026-05-23 18:12:58 +02:00
Elias Stiffel
d337e2204a fix(lint): use optional chain in FolderTree create handler 2026-05-23 18:10:34 +02:00
github-actions[bot]
7110fef6f8 Merge branch 'main' into pr-728 2026-05-23 16:00:39 +00:00
github-actions[bot]
ec286e98d2 Merge branch 'main' into pr-736 2026-05-23 16:00:36 +00:00
github-actions[bot]
66b1a2b301 Merge branch 'main' into pr-734 2026-05-23 16:00:34 +00:00
lucaronin
2a3ecf7270 fix: recover invalid editor block joins 2026-05-23 17:49:00 +02:00
Elias Stiffel
3403c56394 test: align desktop_remote_commands_report_no_remote with has_remote fix
The test's name says "report_no_remote" but its push assertion still
expected the pre-fix behavior ("error"). After 6d97335d made has_remote
URL-aware, git_push on a vault with no URL-bearing remote short-circuits
with "no_remote" — matching the test name and the pull assertion above.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:19:34 +02:00
github-actions[bot]
3042d6180f Merge branch 'main' into pr-728 2026-05-23 15:09:57 +00:00
lucaronin
12646d4e98 fix: recover stale editor block references 2026-05-23 16:53:43 +02:00
github-actions[bot]
972d13e2cc Merge branch 'main' into pr-728 2026-05-23 14:28:31 +00:00
github-actions[bot]
86e88a3d43 Merge branch 'main' into pr-736 2026-05-23 14:28:29 +00:00
lucaronin
f0fb7b7418 fix: recover invalid insertion depth transforms 2026-05-23 16:11:20 +02:00
github-actions[bot]
ba5464cc24 Merge branch 'main' into pr-728 2026-05-23 13:45:10 +00:00
github-actions[bot]
ec20c02784 Merge branch 'main' into pr-736 2026-05-23 13:45:08 +00:00
lucaronin
5bb3fe0550 fix: recover stale table editor transforms 2026-05-23 15:28:35 +02:00
github-actions[bot]
b4610ab9be Merge branch 'main' into pr-728 2026-05-23 12:57:07 +00:00
github-actions[bot]
7c231b08d7 Merge branch 'main' into pr-736 2026-05-23 12:57:04 +00:00
lucaronin
7709ad1002 fix: recover missing note frontmatter writes 2026-05-23 14:41:11 +02:00
lucaronin
948b1501bf fix: route sync to selected git repository 2026-05-23 14:07:56 +02:00
Elias Stiffel
5dd3b98fec fix: avoid duplicate H1 when template begins with one
`buildNoteBody` unconditionally prepended an empty `# ` placeholder
whenever `initialEmptyHeading` was true, which collided with templates
whose first line is already an H1 (e.g. a Weekly Notes type whose
template starts with `# Woche 2026.21`). The placeholder is now
suppressed when the template's first non-whitespace line is `# `.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:35:37 +02:00
Elias Stiffel
f0e32cfd5f fix: nest new folders under the selected parent
The Create Folder action ignored the sidebar selection and always
wrote to the vault root. Now it threads the selected folder as
parent_path to the backend, and expands the parent in the tree so
the new child is visible without re-clicking.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:03:47 +02:00
lucaronin
806df2c586 feat: create notes from quick open 2026-05-21 19:03:13 +02:00
Luca Rossi
3bed1f9235 Merge pull request #717 from mvanhorn/fix/710-numeric-property-rendered-as-ipv4
fix: do not treat pure-numeric property values as bare-domain URLs (#710)
2026-05-21 16:20:10 +02:00
Luca Rossi
07248fb868 Merge pull request #711 from mvanhorn/fix/705-mcp-node-discovery-linuxbrew-fallback
fix: include linuxbrew node path in MCP runtime fallbacks (#705)
2026-05-21 16:19:46 +02:00
github-actions[bot]
8f948d4c41 Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-21 14:19:12 +00:00
github-actions[bot]
cfb8e0aa4f Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-21 14:19:08 +00:00
lucaronin
fa9de47729 Merge remote-tracking branch 'refs/remotes/origin/pr-711' 2026-05-21 16:04:56 +02:00
lucaronin
fe84ed91ac Merge remote-tracking branch 'refs/remotes/origin/pr-716' 2026-05-21 16:00:58 +02:00
lucaronin
b5e0c9d890 Merge remote-tracking branch 'refs/remotes/origin/pr-717' 2026-05-21 15:56:29 +02:00
github-actions[bot]
5d9deb3028 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 13:30:12 +00:00
lucaronin
600ddccff4 docs: align stable release notes filename 2026-05-21 15:29:37 +02:00
github-actions[bot]
e3e7981d8a Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 13:25:11 +00:00
lucaronin
bf1feb204b docs: add stable release notes 2026-05-21 15:24:35 +02:00
github-actions[bot]
2f4d52e53a Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 12:08:49 +00:00
lucaronin
2c9b897430 docs: add Portent templates page 2026-05-21 13:57:23 +02:00
github-actions[bot]
e1486959bb Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 10:13:52 +00:00
lucaronin
74dac301d3 fix: speed up note window startup 2026-05-21 12:01:14 +02:00
github-actions[bot]
2011acf1e2 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 08:33:24 +00:00
github-actions[bot]
50c46f9924 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 08:22:15 +00:00
lucaronin
ecfc76af59 fix: restore note window opening 2026-05-21 10:19:49 +02:00
lucaronin
b2a94b482e chore: tighten eslint pipeline 2026-05-21 10:08:15 +02:00
github-actions[bot]
425393b4a2 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-21 08:03:06 +00:00
lucaronin
ac196bec58 feat: expand note list context menu
Co-authored-by: Brian Lee <engbrianlee@gmail.com>
2026-05-21 09:49:38 +02:00
github-actions[bot]
4e3dfaad55 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 21:32:53 +00:00
lucaronin
d5b2773436 fix: satisfy release project build 2026-05-20 23:19:52 +02:00
github-actions[bot]
695c8423f5 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 18:02:43 +00:00
lucaronin
c7c53cafa2 feat: add note item context menu 2026-05-20 19:50:42 +02:00
github-actions[bot]
6c29a01a69 Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 17:42:41 +00:00
github-actions[bot]
7a72df1c2f Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 17:42:38 +00:00
lucaronin
5f87debecb fix: support go code block highlighting 2026-05-20 19:30:32 +02:00
github-actions[bot]
57c61a1ddd Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 17:27:45 +00:00
github-actions[bot]
08abcd5eff Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 17:27:42 +00:00
github-actions[bot]
bb47cf875d Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-20 17:27:39 +00:00
lucaronin
0a36915903 fix: detect shell-managed pi installs 2026-05-20 19:14:14 +02:00
github-actions[bot]
df1e2913c0 Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 17:09:24 +00:00
github-actions[bot]
ecf4df2d11 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 17:09:21 +00:00
github-actions[bot]
54595ab174 Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-20 17:09:18 +00:00
lucaronin
73742495b0 fix: unzoom fullscreen mermaid diagrams 2026-05-20 18:57:08 +02:00
github-actions[bot]
b93e25ddb1 Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 16:51:53 +00:00
github-actions[bot]
5c5da8a670 Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 16:51:50 +00:00
github-actions[bot]
3a7ce6bb5f Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-20 16:51:46 +00:00
lucaronin
ae6bd8ec73 fix: resolve non-markdown wikilink paths 2026-05-20 18:39:44 +02:00
github-actions[bot]
c7f957775e Merge branch 'main' into fix/705-mcp-node-discovery-linuxbrew-fallback 2026-05-20 16:36:03 +00:00
github-actions[bot]
9ea4c367bd Merge branch 'main' into fix/695-claude-codex-windows-spawn-shim 2026-05-20 16:36:00 +00:00
github-actions[bot]
55c0153692 Merge branch 'main' into fix/710-numeric-property-rendered-as-ipv4 2026-05-20 16:35:57 +00:00
lucaronin
67007e75ff fix: refresh active note after external edits 2026-05-20 18:21:16 +02:00
Matt Van Horn
57c6a4a2a4 fix: do not treat pure-numeric property values as bare-domain URLs
Fixes #710

A number property like "Year: 2026" rendered as "0.0.7.234" in the
note list column because the WHATWG URL parser canonicalizes integer-only
hosts to IPv4 form: new URL('https://2026').hostname === '0.0.7.234'.
formatChipLabel called isUrlValue('2026'), which returned true via the
bare-domain branch of normalizeExternalUrl, and then asked the URL parser
for the hostname.

Add a single guard in normalizeExternalUrl: when the input contains no '.',
reject it before the bare-domain candidate is constructed. The
parseHttpUrl(trimmed) branch above already handles inputs with an explicit
scheme (https://...), so the guard only narrows the bare-domain path -
the path that produced the bug. Existing cases ('example.com', 'localhost')
remain unaffected.

Two new vitest cases cover the regression ('2026' and '0') plus a parity
case for https://example.com to make sure the scheme branch is untouched.
2026-05-19 23:30:59 -07:00
Matt Van Horn
663a7d9a3f fix: prefer .cmd shim over POSIX wrapper for claude/codex on Windows
Fixes #695

`where claude` on Windows returns both the npm-generated POSIX shell
wrapper (extensionless) and the Windows batch shim (`claude.cmd`)
side-by-side. The existing `path_from_successful_output` helpers in
`claude_cli.rs` and `codex_cli.rs` picked the first existing line,
which gave the POSIX wrapper - Windows tried to load it as a PE32,
`CreateProcessW` returned ERROR_BAD_EXE_FORMAT (193), and the AI chat
panel surfaced "%1 is not a valid Win32 application."

Mirror the fix that landed for Gemini in `gemini_discovery.rs`: when
`cfg!(windows)`, filter candidates through the existing shared utility
`cli_agent_runtime::has_windows_cli_extension` so .bat/.cmd/.com/.exe
win over extensionless POSIX scripts. Non-Windows behavior is unchanged.

Added a regression test in each file matching the
`windows_path_lookup_prefers_cmd_shim_over_extensionless_npm_script`
pattern from gemini_discovery.rs.
2026-05-19 23:28:16 -07:00
Matt Van Horn
0abf7b492a fix: include linuxbrew node path in MCP runtime fallbacks
Fixes #705

GUI launches of the AppImage on Linux do not inherit the shell PATH, so
`find_on_path` and `find_in_user_shell` cannot reach Homebrew-on-Linux's
Node install. Add the canonical Linuxbrew paths to `fallback_node_paths`
and to `node_binary_candidates_for_home`; `.is_file()` filtering keeps
machines without Linuxbrew unaffected.
2026-05-19 23:23:58 -07:00
lucaronin
6cf77f9b94 fix: restore autogit multi-vault pushes 2026-05-19 15:46:29 +02:00
lucaronin
2c1fe9f308 fix: reduce retained editor memory 2026-05-19 15:34:46 +02:00
lucaronin
efd3cd4f27 Merge pull request #572 from oksusucha/feat/add-kiro-cli-agent
feat: add Kiro CLI as AI agent

Co-authored-by: oksusucha <sol4877@gmail.com>
2026-05-18 13:55:43 +02:00
lucaronin
3429474fc5 fix: finish pr 412 693 integration 2026-05-18 13:22:09 +02:00
lucaronin
c6ce5f421b fix: save new views in default workspace 2026-05-18 13:17:11 +02:00
lucaronin
7c307e8baf Merge pull request #693 from kzadorozhny/feat/support-bun-runtime
feat: support Bun as MCP server runtime
2026-05-18 13:09:06 +02:00
lucaronin
9d447741f3 Merge pull request #412 from kossoy/main
fix: vault watcher reload loop on iCloud .git symlink vaults
2026-05-18 13:09:00 +02:00
github-actions[bot]
e9079df63c Merge branch 'main' into feat/support-bun-runtime 2026-05-18 10:31:34 +00:00
lucaronin
48fb6710e1 Merge pull request #583 from alroniks/main
feat: add Belarusian basic and latin locales
2026-05-18 12:18:41 +02:00
github-actions[bot]
ae72a43a43 Merge branch 'main' into feat/support-bun-runtime 2026-05-18 09:17:41 +00:00
lucaronin
49935db529 docs: add release notes for v2026-05-18 2026-05-18 11:17:08 +02:00
github-actions[bot]
cd4d3dece2 Merge branch 'main' into feat/support-bun-runtime 2026-05-18 08:43:27 +00:00
lucaronin
0ca0f4a6cf fix: point update banner to release notes 2026-05-18 10:33:50 +02:00
Konstantin Zadorozhny
c2a0988368 docs: note Bun runtime alongside Node for MCP server
Mention `find_mcp_runtime` (Node.js 18+ preferred, Bun 1+ fallback) wherever
the docs previously said Tolaria "verifies Node.js" before writing MCP config,
update the mcp-server directory description, and broaden the AppImage subprocess
sanitisation note to cover both runtimes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:46:59 -07:00
Konstantin Zadorozhny
ab123a1428 feat: support Bun as MCP server runtime
Tolaria's MCP server is pure ESM with only standard `node:fs|path|http|child_process`
imports and pure-JS dependencies, so Bun can execute it identically to Node.
Until now `find_node()` was the single entry point for spawning the WebSocket
bridge and writing external AI tool config — users with Bun but no Node would
hit "node not found in PATH or common install locations" and lose access to MCP
tools entirely.

Introduce `find_mcp_runtime()` which returns the first verifying runtime,
preferring Node 18+ when present and falling back to Bun 1+. The generic
PATH and login-shell lookup helpers (`find_on_path`, `find_in_user_shell`,
`lookup_command`, `lookup_paths`) are now parameterised by command name so
both runtimes share the same machinery. Bun candidates cover `~/.bun/bin/bun`,
mise/asdf/proto shims, Homebrew, and `%USERPROFILE%\.bun\bin\bun.exe` on
Windows. The Codex CLI and Windows .cmd-shim resolution paths keep using the
strict `find_node()` since they specifically need Node.

`spawn_ws_bridge_with_paths`, `mcp_config_snippet`, and `register_mcp` now
resolve through `find_mcp_runtime` so the runtime that gets registered into
`~/.claude.json`, `~/.gemini/settings.json`, `~/.cursor/mcp.json`, etc.
matches the one actually present on the machine.

Locale copy updated from "nodeRequirement" to "runtimeRequirement" across
all 15 catalogs to reflect "Node.js 18+ or Bun 1+".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:42:31 -07:00
lucaronin
c1a5042fb9 test: cover image toolbar hover access 2026-05-17 20:06:16 +02:00
lucaronin
9d331ffda8 fix: respect rtl direction in editor blocks 2026-05-17 19:46:18 +02:00
lucaronin
41992506f1 fix: align raw editor line numbers 2026-05-17 19:34:18 +02:00
lucaronin
761f232647 fix: address clawpatch findings 2026-05-17 19:31:12 +02:00
lucaronin
c09b25d3c5 fix: restore release build type safety 2026-05-17 16:34:09 +02:00
lucaronin
4061d5e88e fix: remove built-in note body templates 2026-05-17 16:31:20 +02:00
lucaronin
0983e4e288 test: cover attachment preservation during tab switch 2026-05-17 04:09:47 +02:00
lucaronin
247c3eefb9 fix: repair rich editor invalid list content 2026-05-17 03:06:49 +02:00
lucaronin
28e2072f9b fix: stabilize mermaid diagram reload clicks 2026-05-17 00:24:17 +02:00
lucaronin
7adecaea60 fix: preserve embedded attachment paths 2026-05-16 19:25:48 +02:00
lucaronin
2616f87185 fix: prevent editor file drop navigation 2026-05-16 18:54:19 +02:00
lucaronin
d81c696742 fix: resolve relative dates in view filters 2026-05-16 18:34:41 +02:00
lucaronin
678cc8f7ba fix: preserve aliased wikilinks in markdown tables 2026-05-16 17:58:44 +02:00
lucaronin
35176d6eff feat: expose rpm download option 2026-05-16 13:44:11 +02:00
lucaronin
42130e116d feat: add note-list search clear action 2026-05-16 13:13:16 +02:00
lucaronin
1040d95eec feat: add granular git controls 2026-05-16 12:28:56 +02:00
lucaronin
480876558a fix: preserve new note edits during empty heading swap 2026-05-16 11:34:59 +02:00
lucaronin
ce04a49bae fix: keep default workspace views mounted 2026-05-15 11:33:55 +02:00
lucaronin
3695b6f3fe fix: mark MCP tools approval-safe 2026-05-15 11:19:07 +02:00
lucaronin
7e50da7816 fix: match scalar array properties in views 2026-05-15 08:33:17 +02:00
lucaronin
17a938f8cf docs: add/update ADR for AppImage media preview fallback (guard — from commit 9b6a43c) 2026-05-15 08:03:35 +02:00
lucaronin
3d2efcedeb fix: bound parsed note-list preload warmups 2026-05-15 03:16:38 +02:00
lucaronin
c7b7b97022 fix: suppress app-owned frontmatter reloads 2026-05-15 02:42:51 +02:00
lucaronin
0c8be62831 refactor: consolidate vault ordering 2026-05-15 02:29:39 +02:00
lucaronin
9b28f60be0 chore: ratchet codescene thresholds 2026-05-15 01:36:10 +02:00
lucaronin
9b6a43cec7 fix: guard linux appimage media previews 2026-05-15 01:22:56 +02:00
lucaronin
384c752c33 docs: add release notes for v2026-05-14 2026-05-14 18:12:17 +02:00
lucaronin
e844cfa0bc feat: support vault reordering 2026-05-14 17:30:13 +02:00
lucaronin
7174061b2a fix: warm parsed blocks for large notes 2026-05-14 17:20:49 +02:00
lucaronin
cf03c01b75 fix: use stock appimage packaging in release 2026-05-14 17:04:36 +02:00
lucaronin
8b83b2c691 fix: preserve appimage plugin name 2026-05-14 16:44:46 +02:00
lucaronin
d445c72edb fix: extract appimage tools in linux release 2026-05-14 16:33:56 +02:00
lucaronin
9a8aacad40 fix: install fuse for linux appimage builds 2026-05-14 16:27:21 +02:00
lucaronin
416131aa8f fix: restore TypeScript build 2026-05-14 16:09:54 +02:00
lucaronin
8b1d2bbed8 fix: support relationship equality filters 2026-05-14 16:01:34 +02:00
github-actions[bot]
9a7a31c47b Merge branch 'main' into fix/view_filters 2026-05-14 13:27:51 +00:00
lucaronin
62e62790c1 feat: stabilize external mcp appimage registration
Co-authored-by: Domenico Lupinetti <domenico@translated.net>
2026-05-14 15:22:47 +02:00
lucaronin
3b2f675264 fix: create notes in selected vault 2026-05-14 15:12:56 +02:00
github-actions[bot]
9706ee8ec2 Merge branch 'main' into fix/view_filters 2026-05-14 11:57:37 +00:00
lucaronin
c9a4b273d6 refactor: simplify file attachment serialization 2026-05-14 13:43:14 +02:00
github-actions[bot]
8fe02f8c26 Merge branch 'main' into fix/view_filters 2026-05-14 11:38:23 +00:00
lucaronin
18f91af77f fix: preserve file attachment raw toggles 2026-05-14 13:29:33 +02:00
github-actions[bot]
1c047079f0 Merge branch 'main' into fix/view_filters 2026-05-14 11:16:48 +00:00
lucaronin
a8273ad7a9 feat: make external mcp vault resolution dynamic
Port the useful dynamic-vault MCP direction from Domenico Lupinetti's proposal into the current mounted-workspace model.

Durable external MCP registration is now vault-neutral, Node MCP resolves active mounted vaults from explicit env or Tolaria vaults.json, and vault context includes root AGENTS.md instructions per active vault.

Based-on: https://github.com/refactoringhq/tolaria/pull/603

Co-authored-by: Domenico Lupinetti <domenico@translated.net>
2026-05-14 13:06:43 +02:00
github-actions[bot]
133b2601a2 Merge branch 'main' into fix/view_filters 2026-05-14 11:06:39 +00:00
lucaronin
21d4577079 fix: apply webkit safeguards on linux wayland 2026-05-14 12:55:09 +02:00
github-actions[bot]
c19003cf39 Merge branch 'main' into fix/view_filters 2026-05-14 10:42:50 +00:00
lucaronin
d99da92b43 fix: preserve windows fullscreen navigation 2026-05-14 12:32:42 +02:00
github-actions[bot]
8b9c4592f1 Merge branch 'main' into fix/view_filters 2026-05-14 10:23:26 +00:00
lucaronin
832feb7df1 fix: standardize icons on phosphor 2026-05-14 12:14:16 +02:00
github-actions[bot]
506e8d0eac Merge branch 'main' into fix/view_filters 2026-05-14 10:11:02 +00:00
lucaronin
7cd346bde0 fix: prevent duplicate vault folder pickers 2026-05-14 11:59:20 +02:00
Andrius Miasnikovas
47f5da1798 Fix equals and not equals filters not working in views 2026-05-14 11:08:48 +03:00
lucaronin
ff4939fb29 fix: recover procedure editor schema errors 2026-05-14 09:55:20 +02:00
lucaronin
3fa1d34c32 fix: stabilize table handle controls after reload 2026-05-14 09:22:44 +02:00
lucaronin
2ef4d95bb5 fix: retarget externally moved inbox notes 2026-05-14 08:15:49 +02:00
lucaronin
ce715ed5aa docs: add/update ADR for entry-scoped note windows (guard — from commit 6bb9c7f) 2026-05-14 08:05:28 +02:00
lucaronin
95dab219b8 fix: preserve cjk code block copy text 2026-05-14 07:48:04 +02:00
lucaronin
2fcea0624d test: stabilize type-derived smoke setup 2026-05-14 06:57:25 +02:00
lucaronin
7f8aad0686 fix: clean up invalid mermaid renders 2026-05-14 06:43:50 +02:00
lucaronin
2eae7a0899 fix: preserve focused editor after pulls 2026-05-14 05:53:37 +02:00
lucaronin
6bb9c7fbc9 fix: avoid note-window vault scans 2026-05-14 05:05:33 +02:00
lucaronin
9d9681345d ci: keep linux check off push lane 2026-05-14 04:05:05 +02:00
lucaronin
201aefca05 fix: bundle fcitx gtk module in appimage 2026-05-13 16:44:38 +02:00
lucaronin
41f0e00cfb fix: support symlinked appimage launches 2026-05-13 16:06:57 +02:00
lucaronin
9be428aad3 fix: sanitize appimage mcp node launches 2026-05-13 15:21:36 +02:00
lucaronin
b2d23a8559 fix: dedupe calendar stable releases 2026-05-13 14:47:26 +02:00
lucaronin
d6ad2ff836 test: isolate pi agent config tests 2026-05-13 14:32:04 +02:00
lucaronin
6ac8f822ff test: stabilize autogit smoke setup 2026-05-13 14:18:03 +02:00
lucaronin
b0c8eff6c7 fix: handle autogit author failures 2026-05-13 14:12:23 +02:00
lucaronin
ae7a468310 test: guard reload editing loop 2026-05-13 13:24:41 +02:00
lucaronin
84a993b948 test: guard fresh-note enter selection 2026-05-13 13:03:36 +02:00
lucaronin
2a5ebcf73d fix: prevent whiteboard dialog crash 2026-05-13 12:42:37 +02:00
lucaronin
09978cf8e5 fix: preserve ampersand in native commit menu 2026-05-13 11:52:06 +02:00
lucaronin
0e981228e2 fix: point release history link to public site 2026-05-13 11:41:36 +02:00
lucaronin
b77ebd2a02 docs: add release notes for v2026-05-12 2026-05-13 11:33:02 +02:00
lucaronin
2e414b0867 fix: restore production build 2026-05-13 10:39:34 +02:00
lucaronin
00fec78b16 fix: stabilize editing and mounted vault updates 2026-05-13 10:29:22 +02:00
lucaronin
e774fcd824 fix: load vault index in note windows 2026-05-13 09:34:11 +02:00
lucaronin
043cdec141 fix: allow default vault notes to move workspaces 2026-05-13 09:18:54 +02:00
lucaronin
11b93638d6 docs: add/update ADR for rich/raw transition ownership (guard — from commit 5020771) 2026-05-13 08:03:41 +02:00
lucaronin
713c2a9dbb fix: set git author before app commits 2026-05-13 07:59:34 +02:00
lucaronin
553ed4de07 fix: recover stale rich-editor transforms 2026-05-13 07:33:37 +02:00
lucaronin
83cf82294c test: preserve raw exit edits during tab state sync 2026-05-13 04:06:34 +02:00
lucaronin
502077170d refactor: consolidate editor mode handoff 2026-05-13 02:18:19 +02:00
lucaronin
16a836b59c fix: register native mac window menu 2026-05-12 23:06:03 +02:00
lucaronin
68374109f4 feat: add ai visibility setting 2026-05-12 22:30:24 +02:00
lucaronin
8a3b6b57f8 fix: restore ai panel view menu 2026-05-12 21:07:40 +02:00
lucaronin
376069d124 fix: preserve pi agent config 2026-05-12 20:18:00 +02:00
lucaronin
1d563a8978 fix: scope saved views to source vault 2026-05-12 19:33:53 +02:00
lucaronin
5215754ffa feat: improve editor code blocks 2026-05-12 18:21:26 +02:00
lucaronin
c0b0a02ce6 fix: show commit dialog opening feedback 2026-05-12 16:28:58 +02:00
lucaronin
dfb363e768 fix: keep mounted workspace notes during vault switch 2026-05-12 16:11:31 +02:00
lucaronin
e82fc964d6 fix: avoid editor stalls during git status refresh 2026-05-12 15:01:41 +02:00
lucaronin
3f029b914c fix: hide startup boot diagnostics 2026-05-12 12:15:09 +02:00
lucaronin
96e8857a77 fix: recover startup vault loading 2026-05-12 11:37:14 +02:00
lucaronin
442b5f0256 fix: normalize renamed save paths 2026-05-12 11:13:41 +02:00
lucaronin
90cfb06e74 fix: narrow manual commit flow config 2026-05-12 10:30:42 +02:00
lucaronin
992c042328 fix: dispatch stable release docs deploy 2026-05-12 10:26:48 +02:00
lucaronin
91ca97bcd4 docs: document multi-vault usage 2026-05-12 10:13:16 +02:00
lucaronin
d3624edf9f refactor: consolidate repository git state 2026-05-12 08:59:30 +02:00
lucaronin
aa942362e9 fix: stabilize editor mode round trips 2026-05-12 08:15:03 +02:00
lucaronin
f2ec12aff6 fix: insert emoji shortcode suggestions 2026-05-12 07:51:10 +02:00
lucaronin
c392b84f3d fix: refresh mounted workspace startup marker 2026-05-12 07:13:01 +02:00
lucaronin
79d3a924f0 refactor: consolidate date display preference flow 2026-05-12 06:57:42 +02:00
lucaronin
b33d1060ef refactor: split vault view helpers 2026-05-12 05:24:51 +02:00
lucaronin
6c90865542 refactor: extract vault workspace entry helpers 2026-05-12 04:57:44 +02:00
lucaronin
956619237e test: preserve editor draft when tab closes 2026-05-12 04:30:41 +02:00
lucaronin
66dfc26bc9 refactor: extract git file workflows 2026-05-12 04:04:31 +02:00
lucaronin
7709255fb3 refactor: extract app orchestration hooks 2026-05-12 03:38:54 +02:00
lucaronin
a3fdaee7ad refactor: extract inbox organize advance 2026-05-12 02:27:53 +02:00
lucaronin
e84bd1a8cd fix: keep note opening responsive 2026-05-11 19:58:01 +02:00
lucaronin
8ff4cfde4f fix: type selected UI language preference 2026-05-11 18:41:25 +02:00
lucaronin
b2a6111a65 refactor: extract app vault setup state 2026-05-11 18:21:12 +02:00
lucaronin
487884bcaf fix: support nested mock Tauri args 2026-05-11 18:21:12 +02:00
lucaronin
07edfac400 feat: support mounted vault workspaces 2026-05-11 18:21:12 +02:00
lucaronin
5c05347690 fix: recover editor block ids before remount 2026-05-11 17:47:15 +02:00
lucaronin
f9fe3d833d fix: restore previous list after neighborhood toggle 2026-05-11 15:47:51 +02:00
lucaronin
7a4d027f12 feat: add collapsed sidebar reopen button 2026-05-11 14:36:20 +02:00
lucaronin
5a14848cf8 fix: tolerate malformed MCP frontmatter 2026-05-11 14:07:23 +02:00
lucaronin
0715f51b02 feat: add date display setting 2026-05-11 13:46:18 +02:00
lucaronin
e727afdd81 fix: pipe oversized claude prompts on windows 2026-05-11 12:30:44 +02:00
lucaronin
6c21aa1087 fix: avoid claude cmd shim on windows 2026-05-11 11:23:39 +02:00
lucaronin
21c68a1138 test: include save-before-switch smoke in core lane 2026-05-11 04:10:16 +02:00
lucaronin
8bee3b51cb fix: launch opencode cmd shims on windows 2026-05-10 11:18:56 +02:00
lucaronin
b4b49eaad2 fix: toggle active neighborhood back to notes 2026-05-10 10:56:57 +02:00
lucaronin
f8515fb191 fix: resolve note-relative markdown images 2026-05-10 10:30:31 +02:00
lucaronin
a2a6feea52 fix: support current Claude and Gemini CLI behavior 2026-05-10 09:58:54 +02:00
lucaronin
b9d417ec1d test: protect Cmd+N note creation persistence 2026-05-10 04:07:41 +02:00
lucaronin
6076332b8b fix: preserve list markers on fresh paste 2026-05-09 18:39:41 +02:00
lucaronin
fb08dd0c71 ci: avoid duplicate frontend typecheck in build gate 2026-05-09 04:05:50 +02:00
lucaronin
064a8879e9 fix: resolve windows mcp server install path 2026-05-08 23:13:41 +02:00
lucaronin
eb731bbaad fix: guard inspector property mutations 2026-05-08 17:37:04 +02:00
lucaronin
852c153d24 fix: avoid math placeholders for currency prose 2026-05-08 17:07:36 +02:00
lucaronin
2b345e9f76 fix: guard inline link clicks during reload 2026-05-08 16:46:22 +02:00
lucaronin
e8e30611fb test: cover rich editor media reload crash 2026-05-08 16:13:52 +02:00
lucaronin
6b71faa6e2 fix: publish docs at custom domain root 2026-05-08 15:51:10 +02:00
lucaronin
dd32f14d3a fix: clear stale editor selection on reload 2026-05-08 15:06:50 +02:00
lucaronin
5a75548652 fix: guard ai markdown highlighting on legacy webkit 2026-05-08 14:59:44 +02:00
lucaronin
81ba8f80d5 fix: tolerate stale file block clicks 2026-05-08 14:19:52 +02:00
lucaronin
ee0d4fc470 fix: allow delete menu clicks in drag regions 2026-05-08 13:44:19 +02:00
lucaronin
f7e9b66988 fix: keep fullscreen mermaid dark themed 2026-05-08 13:24:16 +02:00
lucaronin
91e02f0719 fix: sync whiteboard theme with app mode 2026-05-08 13:04:24 +02:00
lucaronin
4fd0a265f4 fix: tolerate missing note titles in list search 2026-05-08 12:33:04 +02:00
lucaronin
5a2642ba81 fix: allow multimedia embed url input 2026-05-08 12:06:28 +02:00
lucaronin
4870d2a05f fix: keep new note title rename current 2026-05-08 12:06:28 +02:00
lucaronin
6b7de99f78 fix: deploy release pages via github pages 2026-05-08 11:54:48 +02:00
lucaronin
901560467f fix: harden alpha updater metadata lookup 2026-05-08 11:34:27 +02:00
lucaronin
707c164b1c test: harden telemetry onboarding wait 2026-05-08 11:05:46 +02:00
lucaronin
5f00bae2cf fix: preserve slash menu mouse selection 2026-05-08 11:01:44 +02:00
lucaronin
cef3e6a805 fix: allow tldraw context menus in tauri 2026-05-08 10:06:25 +02:00
lucaronin
5e978e315e fix: apply rustfmt to workspace boundary 2026-05-08 01:12:39 +02:00
lucaronin
5e7d29f620 fix: satisfy production workspace build 2026-05-08 01:12:39 +02:00
lucaronin
ef3aed64e3 feat: unify mounted workspace graph 2026-05-08 01:12:39 +02:00
lucaronin
7bda58bbf6 fix: clear codacy focus and history highs 2026-05-07 22:59:58 +02:00
lucaronin
b8d1681aae fix: constrain mock vault api fetches 2026-05-07 22:40:33 +02:00
lucaronin
a29ce24280 fix: clear codacy regex and xss highs 2026-05-07 22:22:29 +02:00
lucaronin
27f1092251 feat: add math slash command 2026-05-07 21:30:19 +02:00
lucaronin
f89c5e8f7d fix: harden mock vault api requests 2026-05-07 21:06:06 +02:00
lucaronin
5965d5f23f fix: clear codacy object injection highs 2026-05-07 20:54:32 +02:00
lucaronin
8d35fbcba3 fix: clear codacy high severity batches 2026-05-07 19:52:14 +02:00
lucaronin
f5866a2376 fix: resolve codacy high severity findings 2026-05-07 18:43:59 +02:00
github-actions[bot]
c571b06293 Merge branch 'main' into main 2026-05-07 16:00:03 +00:00
Luca Rossi
b9ce903991 Updated readme 2026-05-07 17:59:31 +02:00
Ivan Klimchuk
14bf0dc32f fix: resolve pnpm patchedDependencies routing for tests 2026-05-07 18:44:28 +03:00
Ivan Klimchuk
238b4df55f feat: add Belarusian basic and latin locales 2026-05-07 18:44:27 +03:00
lucaronin
3e01c87f64 test: cover note history edit stability 2026-05-07 17:39:35 +02:00
Luca Rossi
f816c9a175 Added Codacy badge 2026-05-07 16:40:49 +02:00
lucaronin
1714b13583 fix: avoid mermaid html label clipping 2026-05-07 16:29:11 +02:00
lucaronin
fb55c57536 fix: show reference titles in breadcrumb 2026-05-07 15:48:29 +02:00
lucaronin
99e509f553 fix: keep whiteboard dialogs usable 2026-05-07 15:27:41 +02:00
lucaronin
09b8a28e39 fix: restore generated release history page 2026-05-07 15:00:28 +02:00
Luca Rossi
e0766dc678 Updated release notes 2026-05-07 14:53:47 +02:00
lucaronin
eed307f3d5 docs: add release notes for v2026-05-07 2026-05-07 14:41:37 +02:00
lucaronin
6ab5c2aa46 fix: keep saved view property sorts visible 2026-05-07 14:04:44 +02:00
lucaronin
9113f44789 test: stabilize type-derived quick open smoke 2026-05-07 13:47:18 +02:00
lucaronin
45643c59a8 fix: improve dark wikilink suggestion highlight 2026-05-07 13:38:29 +02:00
lucaronin
6d6b19f894 fix: use canonical download URL 2026-05-07 13:02:51 +02:00
lucaronin
ea4830ac30 fix: hard navigate to download page 2026-05-07 12:57:25 +02:00
lucaronin
cd3cff733b ci: preserve download assets on docs deploy 2026-05-07 12:38:26 +02:00
lucaronin
a441a6bd6a docs: harden release readiness instructions 2026-05-07 11:46:56 +02:00
lucaronin
e24ea96436 docs: adjust landing hero spacing 2026-05-07 11:24:59 +02:00
lucaronin
496a0373ff fix: resolve linux package mcp server path 2026-05-07 10:58:09 +02:00
lucaronin
8f8b9ada47 fix: launch gemini windows shims 2026-05-07 10:42:56 +02:00
lucaronin
efd3ff3132 ci: keep site updates out of app releases 2026-05-07 10:35:00 +02:00
oksusucha
881f860504 refactor: extract shared binary discovery into cli_agent_runtime
Move common shell lookup logic (PATH check, login shell probe,
first_existing_path) into cli_agent_runtime as find_cli_binary()
and check_cli_availability(). Rewrite kiro_discovery to use these
shared utilities, reducing duplication from 100 lines to ~20.

Other discovery modules (pi, gemini, opencode) can adopt the same
pattern in a follow-up.
2026-05-07 17:15:27 +09:00
oksusucha
596d2b93bb refactor: reduce kiro_cli complexity by extracting helper functions
Split run_agent_stream into focused helpers:
- spawn_kiro_process: command setup and spawn
- write_prompt_async: threaded stdin write
- generate_session_id: unique ID generation
- stream_stdout: line-by-line output processing
- collect_stderr: stderr collection

Reduces per-function cyclomatic complexity from ~18 to ~6.
2026-05-07 17:13:16 +09:00
oksusucha
16ce8b105c feat: add Kiro CLI as AI agent (rebuilt on upstream architecture)
- Add kiro_discovery.rs: binary detection (PATH, login shell, known paths)
- Add kiro_cli.rs: streaming via stdin, ANSI stripping, MCP config merge
- Register Kiro in ai_agents.rs enum, status, and dispatcher
- Add 'kiro' to settings normalization
- Add Kiro to frontend agent definitions and helpers
- Update test for agent cycling

Kiro uses plain text streaming (not JSON) so it has its own stream
handler rather than using cli_agent_runtime::run_ai_agent_json_stream.
MCP config merges into existing .kiro/settings/mcp.json to preserve
user-configured servers. Prompt is passed via stdin to avoid E2BIG.
2026-05-07 17:08:44 +09:00
lucaronin
abc6f7d9b1 docs: deploy public site with GitHub Pages 2026-05-07 09:29:47 +02:00
lucaronin
a59582c067 fix: normalize agent docs paths for windows 2026-05-07 09:08:11 +02:00
lucaronin
afc9face71 docs: add/update ADR for shared attachment path normalization (guard — from commit c8cc9b1) 2026-05-07 08:07:20 +02:00
lucaronin
7bbff355f3 fix: normalize note open entries 2026-05-07 06:41:50 +02:00
lucaronin
8707c9e4be fix: recover stale BlockNote render ids 2026-05-07 06:22:39 +02:00
lucaronin
a92651e048 fix(whiteboard): guard missing tldraw text measurements 2026-05-07 04:13:46 +02:00
lucaronin
c8cc9b1bf1 refactor: consolidate vault attachment paths 2026-05-07 03:05:10 +02:00
lucaronin
8b39eb92d6 fix(ai): handle command backspace in composer 2026-05-07 02:05:50 +02:00
lucaronin
5a02b9240a fix(editor): open PDF attachments from notes 2026-05-07 00:55:07 +02:00
lucaronin
d211d89ab8 fix: clear stale editor state on vault switch 2026-05-07 00:25:15 +02:00
lucaronin
5c3a49d2ca fix: handle shorthand type sort fields 2026-05-06 23:19:50 +02:00
lucaronin
1eeda047c2 feat: bundle docs for Tolaria agents 2026-05-06 23:08:42 +02:00
lucaronin
55dcdd4c87 fix: allow local model network access on macos 2026-05-06 18:26:57 +02:00
Luca Rossi
366d240cc4 Updated release notes 2026-05-06 17:07:45 +02:00
lucaronin
e19db5cab7 feat: add table of contents shortcut 2026-05-06 16:45:18 +02:00
lucaronin
74eecfda02 docs: add release notes for v2026-05-06 2026-05-06 16:22:11 +02:00
lucaronin
d093bd44bc fix(git): use macos keychain credentials for remotes 2026-05-06 16:09:33 +02:00
lucaronin
2e7c5cb433 fix: restore native image attachments 2026-05-06 14:47:43 +02:00
lucaronin
41ee40d427 fix(type-actions): handle type filename collisions 2026-05-06 14:30:03 +02:00
lucaronin
5bf59a9d2f fix(note-list): keep status dot steady while typing 2026-05-06 13:42:04 +02:00
lucaronin
ee24982c72 fix(editor): repair malformed numbered list blocks 2026-05-06 13:15:40 +02:00
lucaronin
755a743e93 fix(download): keep public installer page visible 2026-05-06 12:46:12 +02:00
lucaronin
35be37d256 fix(onboarding): keep ai setup dialog scrollable 2026-05-06 12:22:35 +02:00
lucaronin
9c0d0a8566 chore: ratchet codescene threshold 2026-05-06 12:04:45 +02:00
lucaronin
0294bb6fc0 fix(ai): preserve composer focus after replies 2026-05-06 12:04:45 +02:00
lucaronin
dee1c8b1e8 fix: refine frontmatter date picker 2026-05-06 11:52:15 +02:00
lucaronin
e22b4047ed refactor(test): improve app test harness health 2026-05-06 11:17:46 +02:00
lucaronin
691f5eec7c fix: expand editor whitespace selection hit area 2026-05-06 10:49:06 +02:00
lucaronin
7cf2a077ed test: cover editor flush when returning to switched note 2026-05-06 04:08:58 +02:00
lucaronin
b40c1051af feat(properties): speed up distant date edits 2026-05-05 20:44:27 +02:00
lucaronin
7a274d3a83 fix(editor): guard stale whiteboard block writes 2026-05-05 19:54:09 +02:00
lucaronin
634975e505 fix(vault): coalesce background file activity 2026-05-05 19:23:12 +02:00
lucaronin
88f5d04b8c fix(editor): preserve exact copied text 2026-05-05 18:18:56 +02:00
lucaronin
59408bbebd fix: raise CI Node heap for release builds 2026-05-05 17:36:29 +02:00
lucaronin
bc97ade133 fix(editor): support whitespace drag selection 2026-05-05 16:51:56 +02:00
lucaronin
eb6b58eb36 fix: refine breadcrumb overflow actions 2026-05-05 15:46:18 +02:00
lucaronin
520b2c9799 feat(settings): follow system theme 2026-05-05 15:00:24 +02:00
lucaronin
d5ecd567d0 fix(editor): guard stale wikilink click targets 2026-05-05 13:43:48 +02:00
lucaronin
f88b359bb5 fix(editor): render wikilinks inside tables 2026-05-05 13:02:56 +02:00
lucaronin
67ada08e05 fix(editor): ignore stale history diff responses 2026-05-05 12:29:46 +02:00
lucaronin
6ce04eead6 fix(ai): avoid Codex Windows batch shims 2026-05-05 11:45:56 +02:00
lucaronin
b8e3d8193c fix(editor): guard stale table handle state 2026-05-05 11:20:43 +02:00
lucaronin
9c09b5ad78 fix(editor): guard composing enter in list items 2026-05-05 10:15:59 +02:00
lucaronin
a6caa2b069 fix(linux): enable fcitx input for AppImage Wayland 2026-05-05 09:54:39 +02:00
lucaronin
0f231048cf fix(vault): retry transient access denied saves 2026-05-05 09:32:08 +02:00
lucaronin
8a3d00e8c6 docs: add/update ADR for media previews and vault refresh policy (guard — from commit d347cda, 92c0b89) 2026-05-05 09:32:08 +02:00
lucaronin
bfd175224a ci: split frontend and rust quality lanes 2026-05-05 04:06:28 +02:00
lucaronin
0df9c1d707 fix(types): normalize built-in notes type creation 2026-05-05 03:41:41 +02:00
lucaronin
ccfa148014 fix(nav): guard stale entity backlink metadata 2026-05-05 03:19:51 +02:00
lucaronin
dda4943425 test(editor): cover mermaid property refresh 2026-05-05 03:00:47 +02:00
lucaronin
be87c3864c fix(ai): remount composer after native input 2026-05-05 02:43:49 +02:00
lucaronin
982d6476d7 fix(editor): guard stale suggestion query cleanup 2026-05-05 02:19:49 +02:00
lucaronin
92c0b895dc fix(editor): preserve focus during vault refresh 2026-05-05 02:00:28 +02:00
lucaronin
75a8444b41 fix(window): preserve windows properties geometry 2026-05-05 01:43:13 +02:00
lucaronin
d347cda9e3 fix(preview): allow native media playback 2026-05-05 01:08:33 +02:00
lucaronin
f5adbf9cf7 feat(settings): add note display preferences 2026-05-04 22:07:27 +02:00
lucaronin
19059fbf81 fix(frontmatter): preserve crlf inbox organization 2026-05-04 20:35:01 +02:00
lucaronin
1694e3ad07 fix(editor): route clipboard images to attachments 2026-05-04 19:12:14 +02:00
lucaronin
e46613b82b fix: stabilize tldraw whiteboard assets 2026-05-04 18:34:07 +02:00
김덕환
38ea51627d fix(i18n): correct Korean locale mistranslations (#524)
- Inbox: '받은 편지함' → 'Inbox' (이메일 용어 오역. 다른 언어 전부 Inbox 유지)
- Note: '메모' → '노트' (앱 전체 용어 불일치 수정)
- Vault: '보관소/보관함' → 'Vault' (다른 언어 전부 Vault 유지)
- Views: '조회수' → '뷰' (YouTube 조회수 뉘앙스 제거)
- Guidance: '가이드ANCE' → '가이던스' (오타 수정)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-04 18:05:19 +02:00
lucaronin
e5fa40b826 docs: add release notes for v2026-05-04 2026-05-04 16:52:41 +02:00
lucaronin
ff4a78f964 feat: load readable release notes dynamically 2026-05-04 15:07:09 +02:00
lucaronin
9973d8d4e6 fix: bundle tldraw whiteboard assets 2026-05-04 14:57:09 +02:00
lucaronin
e7e33c479d fix: make AI target selection exclusive 2026-05-04 13:30:42 +02:00
lucaronin
69b9da6da6 feat: add readable stable release notes 2026-05-04 13:01:06 +02:00
lucaronin
71ef8325a0 feat: add lazy table of contents panel 2026-05-04 12:48:18 +02:00
lucaronin
582d1d25d6 fix: stabilize embedded diagrams 2026-05-04 11:28:53 +02:00
lucaronin
ebf4545d46 fix: show type-derived instance property placeholders 2026-05-04 10:55:04 +02:00
lucaronin
3c483ba0e6 feat(editor): add table of contents panel 2026-05-04 06:08:09 +02:00
lucaronin
71629763ee chore: ignore local codacy runtime 2026-05-04 05:25:43 +02:00
lucaronin
417e37f1d1 refactor(paths): share note path identity 2026-05-04 04:59:50 +02:00
lucaronin
a3e3192b66 refactor(editor): share durable block codecs 2026-05-04 04:09:14 +02:00
lucaronin
86c503c1f9 refactor(ai): share model provider catalog 2026-05-04 02:59:20 +02:00
lucaronin
21a47b4a77 fix(editor): avoid modern array copy in rich export 2026-05-04 02:07:32 +02:00
lucaronin
64d961bd98 fix(editor): type ime cancel listener 2026-05-04 01:18:28 +02:00
lucaronin
1b218f5250 fix(editor): keep toolbar hidden through ime settle 2026-05-04 01:02:07 +02:00
lucaronin
dfb9f98b7a fix(types): block same-path type collisions 2026-05-03 23:44:24 +02:00
lucaronin
6a033cd2db fix(status): keep git popup above panels 2026-05-03 20:25:50 +02:00
lucaronin
b872b6148c fix(menu): route windows menu actions 2026-05-03 20:00:37 +02:00
github-actions[bot]
e7d74a7880 Merge branch 'main' into main 2026-04-29 19:58:07 +00:00
github-actions[bot]
8cb5d0fd88 Merge branch 'main' into main 2026-04-29 19:42:54 +00:00
github-actions[bot]
cebcd17db0 Merge branch 'main' into main 2026-04-29 19:40:29 +00:00
github-actions[bot]
4276c1c3ac Merge branch 'main' into main 2026-04-29 19:39:54 +00:00
github-actions[bot]
fa3cf3f7f3 Merge branch 'main' into main 2026-04-29 19:27:18 +00:00
github-actions[bot]
8d761751e7 Merge branch 'main' into main 2026-04-29 19:21:47 +00:00
github-actions[bot]
eabab20453 Merge branch 'main' into main 2026-04-29 19:18:49 +00:00
github-actions[bot]
fdd6a64c72 Merge branch 'main' into main 2026-04-29 18:55:44 +00:00
github-actions[bot]
2db83d0810 Merge branch 'main' into main 2026-04-29 18:23:25 +00:00
github-actions[bot]
cdb303d98c Merge branch 'main' into main 2026-04-29 18:05:55 +00:00
github-actions[bot]
fe2a35b09e Merge branch 'main' into main 2026-04-29 17:40:28 +00:00
github-actions[bot]
816145ea0f Merge branch 'main' into main 2026-04-29 16:32:08 +00:00
github-actions[bot]
5d3661995c Merge branch 'main' into main 2026-04-29 15:25:20 +00:00
github-actions[bot]
c06b673e62 Merge branch 'main' into main 2026-04-29 12:34:15 +00:00
github-actions[bot]
45e7416267 Merge branch 'main' into main 2026-04-29 12:24:43 +00:00
github-actions[bot]
743b259fb5 Merge branch 'main' into main 2026-04-29 11:59:15 +00:00
github-actions[bot]
154e6f68be Merge branch 'main' into main 2026-04-29 11:46:39 +00:00
github-actions[bot]
053404afee Merge branch 'main' into main 2026-04-29 11:29:05 +00:00
github-actions[bot]
459eae1334 Merge branch 'main' into main 2026-04-29 10:22:51 +00:00
github-actions[bot]
22b366d6f9 Merge branch 'main' into main 2026-04-29 10:00:02 +00:00
github-actions[bot]
9540a4e9dc Merge branch 'main' into main 2026-04-29 09:28:10 +00:00
github-actions[bot]
de1cad448b Merge branch 'main' into main 2026-04-29 08:54:27 +00:00
github-actions[bot]
1133b8ee6f Merge branch 'main' into main 2026-04-29 08:42:58 +00:00
github-actions[bot]
ae4dbefe83 Merge branch 'main' into main 2026-04-29 08:31:25 +00:00
github-actions[bot]
2f1ffeffcb Merge branch 'main' into main 2026-04-29 08:17:58 +00:00
github-actions[bot]
a3a89ef483 Merge branch 'main' into main 2026-04-29 06:19:49 +00:00
github-actions[bot]
6c533b7055 Merge branch 'main' into main 2026-04-29 05:56:55 +00:00
github-actions[bot]
d54467cbfe Merge branch 'main' into main 2026-04-29 05:09:38 +00:00
github-actions[bot]
359103b0d9 Merge branch 'main' into main 2026-04-29 04:50:54 +00:00
github-actions[bot]
2302594c5c Merge branch 'main' into main 2026-04-29 04:21:53 +00:00
github-actions[bot]
e226804947 Merge branch 'main' into main 2026-04-29 03:41:19 +00:00
github-actions[bot]
165697f678 Merge branch 'main' into main 2026-04-29 03:17:43 +00:00
github-actions[bot]
a6a8146975 Merge branch 'main' into main 2026-04-29 03:00:21 +00:00
github-actions[bot]
ec03ffc7a9 Merge branch 'main' into main 2026-04-29 02:47:15 +00:00
github-actions[bot]
beeecb1845 Merge branch 'main' into main 2026-04-29 02:11:15 +00:00
github-actions[bot]
f7a54593ac Merge branch 'main' into main 2026-04-29 01:43:08 +00:00
github-actions[bot]
6209b0baa5 Merge branch 'main' into main 2026-04-29 01:16:17 +00:00
github-actions[bot]
8739605572 Merge branch 'main' into main 2026-04-29 00:48:11 +00:00
github-actions[bot]
f18a309469 Merge branch 'main' into main 2026-04-29 00:36:54 +00:00
github-actions[bot]
aa1d54f4c9 Merge branch 'main' into main 2026-04-28 23:54:31 +00:00
github-actions[bot]
f816be5bbe Merge branch 'main' into main 2026-04-28 23:28:37 +00:00
github-actions[bot]
101423c977 Merge branch 'main' into main 2026-04-28 22:27:31 +00:00
github-actions[bot]
e66c81d896 Merge branch 'main' into main 2026-04-28 22:04:32 +00:00
github-actions[bot]
e399d4c1fe Merge branch 'main' into main 2026-04-28 21:50:18 +00:00
github-actions[bot]
63c5c5e6b1 Merge branch 'main' into main 2026-04-28 21:39:27 +00:00
github-actions[bot]
d39fa1aa9d Merge branch 'main' into main 2026-04-28 21:09:09 +00:00
Oleg Kossoy
52aae9ed96 fix: tolerate non-JS pnpm binary in coverage runner
`run-vitest-coverage.mjs` reused `process.env.npm_execpath` with
`process.execPath` (node) for a fast path that skips the pnpm wrapper.
That assumes `npm_execpath` is a JS file, but standalone pnpm installs
ship a native Mach-O / ELF binary, so node tries to load the binary as
a CJS module and crashes with `SyntaxError: Invalid or unexpected
token`, taking the pre-push hook down with it.

Only take the fast path when the path actually looks like a JS module
(`.js`/`.mjs`/`.cjs`); otherwise fall back to invoking `pnpm` from
PATH, which is what the script already does when `npm_execpath` is
unset.
2026-04-28 23:49:40 +03:00
Oleg Kossoy
f72b7d3361 fix: ignore .git symlink targets in vault watcher
Vaults stored on iCloud / Dropbox commonly use a `.git -> .git.nosync`
symlink so the cloud sync engine ignores git internals. Background
`git fetch` calls (auto-pull, remote-status, gitstatusd) wrote to the
real `.git.nosync/` directory, which the watcher's `.git` component
filter did not match — every fetch fired a `vault-changed` event and
triggered `reloadVault`, flickering the "Reload vault" status badge
multiple times per second.

Resolve the real git directory at watcher start (symlink, regular dir,
or `gitdir:` pointer for worktrees/submodules) and reject any path
inside it. Also filter `.gitstatus.*` cache dirs and `*.icloud`
placeholders, which are noisy on the same setups.
2026-04-28 23:49:40 +03:00
1281 changed files with 94512 additions and 38621 deletions

17
.codacy.yaml Normal file
View File

@@ -0,0 +1,17 @@
---
exclude_paths:
- "coverage/**"
- "dist/**"
- "e2e/**"
- "node_modules/**"
- "scripts/**"
- "src/test/**"
- "src-tauri/gen/**"
- "src-tauri/resources/agent-docs/**"
- "src-tauri/target/**"
- "target/**"
- "test-results/**"
- "tests/**"
- "**/*.test.ts"
- "**/*.test.tsx"
- "vite.config.ts"

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=10.0
AVERAGE_THRESHOLD=9.92
AVERAGE_THRESHOLD=9.95

View File

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

View File

@@ -14,10 +14,12 @@ permissions:
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
# Keep large production frontend builds below CI runner memory limits.
NODE_OPTIONS: --max-old-space-size=4096
jobs:
test:
name: Tests & Quality Checks
frontend-quality:
name: Frontend Tests & Quality Checks
runs-on: macos-15
steps:
@@ -26,7 +28,7 @@ jobs:
fetch-depth: 0 # Full history for CodeScene
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
@@ -36,38 +38,25 @@ jobs:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy, llvm-tools-preview
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Keep frontend and Rust quality gates in separate macOS jobs so the
# expensive Rust target cache restore no longer blocks the frontend lane.
# ── 0. Build check (catches type errors and bundler failures) ─────────
- name: TypeScript type check
run: pnpm exec tsc --noEmit
- name: Vite build check
run: pnpm build
# TypeScript is checked explicitly above; run Vite directly here to avoid
# paying for the package build script's duplicate `tsc -b` pass.
run: pnpm exec vite build
- name: Docs build check
run: pnpm docs:build
# ── 1. Coverage-backed tests ──────────────────────────────────────────
# The coverage commands run the same frontend and Rust test suites, so keep
# them as the canonical test lane instead of running every suite twice.
# The coverage command runs the canonical frontend test suite.
- name: Bundle MCP server resources (required by Tauri build)
run: node scripts/bundle-mcp-server.mjs
@@ -76,25 +65,15 @@ jobs:
run: pnpm test:coverage
# Thresholds configured in vite.config.ts — exits non-zero if coverage drops
- name: Rust tests + coverage (≥85% lines)
run: |
cargo llvm-cov \
--manifest-path src-tauri/Cargo.toml \
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
--lcov \
--output-path coverage/rust.lcov \
--fail-under-lines 85
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
- name: Upload coverage to Codecov
- name: Upload frontend coverage to Codecov
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe
with:
use_oidc: true
fail_ci_if_error: true
disable_search: true
files: ./coverage/lcov.info,./coverage/rust.lcov
files: ./coverage/lcov.info
flags: frontend
verbose: true
# OIDC avoids long-lived CODECOV_TOKEN secrets.
@@ -158,6 +137,56 @@ jobs:
- name: Lint frontend
run: pnpm lint
rust-quality:
name: Rust Tests & Quality Checks
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
components: rustfmt, clippy, llvm-tools-preview
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@e5de28abeb52d916c5e5875d54b21a9e738b61ec
- name: Rust tests + coverage (≥85% lines)
run: |
mkdir -p coverage
cargo llvm-cov \
--manifest-path src-tauri/Cargo.toml \
--ignore-filename-regex 'lib\.rs|main\.rs|menu\.rs' \
--lcov \
--output-path coverage/rust.lcov \
--fail-under-lines 85
# cargo-llvm-cov exits non-zero if line coverage drops below 85%
# lib.rs/main.rs/menu.rs are Tauri boilerplate -- not meaningfully unit-testable.
- name: Upload Rust coverage to Codecov
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe
with:
use_oidc: true
fail_ci_if_error: true
disable_search: true
files: ./coverage/rust.lcov
flags: rust
verbose: true
# OIDC avoids long-lived CODECOV_TOKEN secrets.
- name: Clippy (Rust)
run: cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
@@ -166,6 +195,11 @@ jobs:
linux-build:
name: Linux build verification
# Keep the normal push CI lane under the 10-minute target. The release
# workflows already perform the full Linux/AppImage build after main
# pushes, so this slower compatibility check stays available for PRs and
# manual diagnostics without blocking every direct push.
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-22.04
steps:
@@ -180,13 +214,14 @@ jobs:
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
libfuse2 \
librsvg2-dev \
patchelf \
build-essential \
file
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
@@ -197,7 +232,7 @@ jobs:
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
with:
components: clippy

104
.github/workflows/deploy-docs.yml vendored Normal file
View File

@@ -0,0 +1,104 @@
name: Deploy docs
on:
push:
branches: [main]
paths:
- ".github/workflows/deploy-docs.yml"
- "package.json"
- "pnpm-lock.yaml"
- "scripts/build-agent-docs.mjs"
- "site/**"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
env:
NODE_OPTIONS: --max-old-space-size=4096
jobs:
build:
name: Build VitePress site
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: pnpm
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: latest
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs and download pages
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pnpm docs:build
DIST="site/.vitepress/dist"
mkdir -p "$DIST/alpha" "$DIST/stable" "$DIST/download" "$DIST/releases" "$DIST/stable/download"
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > "$DIST/releases.json"
STABLE_TAG="$(gh release list --repo ${{ github.repository }} --limit 100 --json tagName,isDraft,isPrerelease --jq '[.[] | select(.isDraft == false and .isPrerelease == false)][0].tagName // ""')"
if [ -n "$STABLE_TAG" ]; then
gh release download --repo ${{ github.repository }} "$STABLE_TAG" --pattern "stable-latest.json" --output "$DIST/stable/latest.json" || echo '{}' > "$DIST/stable/latest.json"
else
echo '{}' > "$DIST/stable/latest.json"
fi
ALPHA_TAG="$(gh release list --repo ${{ github.repository }} --limit 100 --json tagName,isDraft,isPrerelease --jq '[.[] | select(.isDraft == false and .isPrerelease == true)][0].tagName // ""')"
if [ -n "$ALPHA_TAG" ]; then
gh release download --repo ${{ github.repository }} "$ALPHA_TAG" --pattern "alpha-latest.json" --output "$DIST/alpha/latest.json" || echo '{}' > "$DIST/alpha/latest.json"
else
echo '{}' > "$DIST/alpha/latest.json"
fi
bun scripts/build-release-download-page.ts --latest-json "$DIST/stable/latest.json" --releases-json "$DIST/releases.json" --output-file "$DIST/download/index.html"
bun scripts/build-release-history-page.ts --releases-json "$DIST/releases.json" --output-file "$DIST/releases/index.html"
cp "$DIST/download/index.html" "$DIST/stable/download/index.html"
cp "$DIST/alpha/latest.json" "$DIST/latest.json"
cp "$DIST/alpha/latest.json" "$DIST/latest-canary.json"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: site/.vitepress/dist
deploy:
name: Deploy to GitHub Pages
needs: build
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4

View File

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

View File

@@ -4,10 +4,7 @@ on:
push:
tags:
- 'stable-v*'
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
- 'v20*'
concurrency:
group: release-stable-${{ github.ref }}
@@ -34,14 +31,24 @@ jobs:
from datetime import date
tag = os.environ["GITHUB_REF_NAME"]
version = tag.removeprefix("stable-v")
match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})", version)
if not match:
raise SystemExit(f"Stable tags must use stable-vYYYY.M.D, got {tag}")
legacy_match = re.fullmatch(r"stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})", tag)
date_match = re.fullmatch(r"v(\d{4})-(\d{2})-(\d{2})", tag)
if date_match:
year, month, day = map(int, date_match.groups())
date(year, month, day)
version = f"{year}.{month}.{day}"
display_version = tag
elif legacy_match:
year, month, day = map(int, legacy_match.groups())
date(year, month, day)
version = f"{year}.{month}.{day}"
display_version = version
else:
raise SystemExit(f"Stable tags must use vYYYY-MM-DD or stable-vYYYY.M.D, got {tag}")
date(*map(int, match.groups()))
print(f"version={version}")
print(f"display_version={version}")
print(f"display_version={display_version}")
print(f"tag={tag}")
PY
@@ -49,464 +56,32 @@ jobs:
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build release bundles in parallel
# ─────────────────────────────────────────────────────────────
build:
name: Build (${{ matrix.arch }})
# -------------------------------------------------------------
# Phase 2: Build shared release artifacts
# -------------------------------------------------------------
build-artifacts:
name: Build release artifacts
needs: version
runs-on: macos-15
strategy:
fail-fast: true
matrix:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target ${{ matrix.target }}
- name: Upload .dmg
uses: actions/upload-artifact@v4
with:
name: dmg-${{ matrix.arch }}
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
retention-days: 1
- name: Upload updater artifacts (.tar.gz + .sig)
uses: actions/upload-artifact@v4
with:
name: updater-${{ matrix.arch }}
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
needs: version
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Build Tauri app (Linux bundles)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Cache Tauri Windows tools
uses: actions/cache@v4
with:
path: ~\AppData\Local\tauri
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
- name: Prefetch Tauri NSIS toolchain
shell: pwsh
run: ./.github/scripts/prefetch-tauri-nsis.ps1
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1
uses: ./.github/workflows/release-build-artifacts.yml
with:
version: ${{ needs.version.outputs.version }}
macos_bundles: ""
upload_macos_dmg: true
# One-time stable promotion exceptions while Windows certificate secrets
# are still being provisioned. Future stable tags must keep Authenticode.
require_windows_authenticode: ${{ !contains(fromJson('["v2026-06-01","v2026-06-06"]'), needs.version.outputs.tag) }}
secrets: inherit
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (stable)
needs: [version, build, build-linux, build-windows]
needs: [version, build-artifacts]
runs-on: ubuntu-latest
permissions:
contents: write
pages: write
steps:
- uses: actions/checkout@v4
with:
@@ -565,16 +140,23 @@ jobs:
- name: Generate release notes
run: |
PREV_TAG=$(git tag --list 'stable-v*' --sort=-version:refname | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
if [ -z "$PREV_TAG" ]; then
NOTES=$(git log --oneline --no-merges -20)
NOTES_FILE="release-notes/${{ needs.version.outputs.tag }}.md"
if [ -f "$NOTES_FILE" ]; then
cat "$NOTES_FILE" > release_notes.md
else
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
PREV_TAG=$(git for-each-ref --sort=-creatordate --format='%(refname:short)' refs/tags/v20* refs/tags/stable-v* | grep -vx "${{ needs.version.outputs.tag }}" | head -n 1 || echo "")
if [ -z "$PREV_TAG" ]; then
NOTES=$(git log --oneline --no-merges -20)
else
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
fi
{
echo "## What's Changed"
echo ""
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
} > release_notes.md
fi
{
echo "## What's Changed"
echo ""
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
echo ""
echo "---"
echo "**Stable release — manually promoted from \`main\`**"
@@ -582,7 +164,7 @@ jobs:
echo "**Includes macOS (Apple Silicon and Intel), Windows x64, and Linux x64 bundles**"
echo ""
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
} >> release_notes.md
- name: Build stable-latest.json
run: |
@@ -661,7 +243,7 @@ jobs:
echo "stable-latest.json:"; cat stable-latest.json
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
with:
tag_name: ${{ needs.version.outputs.tag }}
name: Tolaria ${{ needs.version.outputs.display_version }}
@@ -704,46 +286,18 @@ jobs:
stable-latest.json
# ─────────────────────────────────────────────────────────────
# Phase 4: Update GitHub Pages
# Phase 4: Trigger the main-branch GitHub Pages deployment
# ─────────────────────────────────────────────────────────────
pages:
name: Update release history page
name: Update docs and release pages
needs: [version, release]
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: github-pages
cancel-in-progress: false
actions: write
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build release history page
- name: Dispatch docs deployment from main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site/alpha _site/stable
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./_site
commit_message: "Update release history for ${{ needs.version.outputs.tag }}"
gh workflow run deploy-docs.yml --repo ${{ github.repository }} --ref main
echo "Triggered deploy-docs.yml on main after publishing ${{ needs.version.outputs.tag }}."

View File

@@ -4,10 +4,11 @@ on:
push:
branches:
- main
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
paths-ignore:
- ".husky/**"
- ".github/workflows/deploy-docs.yml"
- ".github/workflows/release.yml"
- "site/**"
concurrency:
group: release-alpha-${{ github.ref }}
@@ -69,10 +70,18 @@ jobs:
else:
today = datetime.now(timezone.utc).date()
stable_date = None
stable_pattern = re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$")
stable_patterns = (
re.compile(r"^v(\d{4})-(\d{2})-(\d{2})$"),
re.compile(r"^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$"),
)
for stable_tag in lines(["git", "tag", "--list", "stable-v*", "--sort=-version:refname"]):
match = stable_pattern.fullmatch(stable_tag)
stable_tags = lines([
"git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)",
"refs/tags/v20*", "refs/tags/stable-v*",
])
for stable_tag in stable_tags:
match = next((pattern.fullmatch(stable_tag) for pattern in stable_patterns if pattern.fullmatch(stable_tag)), None)
if not match:
continue
@@ -107,460 +116,31 @@ jobs:
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
echo "### Alpha version: \`$DISPLAY_VERSION\` (\`$VERSION\`)" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build each architecture in parallel
# tauri build handles signing automatically via env vars
# ─────────────────────────────────────────────────────────────
build:
name: Build (${{ matrix.arch }})
# -------------------------------------------------------------
# Phase 2: Build shared release artifacts
# -------------------------------------------------------------
build-artifacts:
name: Build release artifacts
needs: version
runs-on: macos-15
strategy:
fail-fast: true
matrix:
include:
- arch: aarch64
target: aarch64-apple-darwin
- arch: x86_64
target: x86_64-apple-darwin
steps:
- uses: actions/checkout@v4
uses: ./.github/workflows/release-build-artifacts.yml
with:
version: ${{ needs.version.outputs.version }}
macos_bundles: app
upload_macos_dmg: false
require_windows_authenticode: false
secrets: inherit
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/${{ matrix.target }}/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Validate telemetry env
env:
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
python3 <<'PY'
import os
import re
import sys
from urllib.parse import urlparse
DISALLOWED_PLACEHOLDERS = {
"",
"-",
"_",
"false",
"true",
"null",
"undefined",
"none",
"disabled",
}
def normalize(name: str) -> str:
value = os.getenv(name, "").strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
value = value[1:-1].strip()
return value
def normalize_http_like(value: str) -> str:
if "://" in value:
return value
return f"https://{value}"
def normalize_hostname(hostname: str) -> str:
normalized = hostname.strip().rstrip('.').lower()
if normalized.startswith('[') and normalized.endswith(']'):
normalized = normalized[1:-1]
return normalized
def is_ip_address(hostname: str) -> bool:
if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", hostname):
return all(0 <= int(part) <= 255 for part in hostname.split('.'))
return ':' in hostname and re.fullmatch(r"[\da-f:]+", hostname, re.IGNORECASE) is not None
def is_allowed_hostname(hostname: str) -> bool:
normalized = normalize_hostname(hostname)
if not normalized or normalized in DISALLOWED_PLACEHOLDERS:
return False
if normalized == 'localhost':
return True
return '.' in normalized or is_ip_address(normalized)
def is_http_url(value: str) -> bool:
parsed = urlparse(normalize_http_like(value))
return parsed.scheme in {"http", "https"} and is_allowed_hostname(parsed.hostname or "")
values = {
name: normalize(name)
for name in (
"VITE_SENTRY_DSN",
"SENTRY_DSN",
"VITE_POSTHOG_KEY",
"VITE_POSTHOG_HOST",
)
}
errors = []
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
value = values[name]
if value.lower() in DISALLOWED_PLACEHOLDERS:
errors.append(f"{name} must be set to a real value, not a placeholder")
elif not is_http_url(value):
errors.append(f"{name} must be a valid http(s) URL with a non-placeholder host")
if values["VITE_POSTHOG_KEY"].lower() in DISALLOWED_PLACEHOLDERS:
errors.append("VITE_POSTHOG_KEY must be set to a real project API key, not a placeholder")
if errors:
print("Telemetry env validation failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
raise SystemExit(1)
print("Telemetry env validation passed.")
PY
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
# Alpha releases only need the notarized app bundle and updater tarball.
# Skipping DMG packaging avoids fragile bundle_dmg.sh failures on macOS runners.
pnpm tauri build --target ${{ matrix.target }} --bundles app
- name: Upload updater artifacts (.tar.gz + .sig)
uses: actions/upload-artifact@v4
with:
name: updater-${{ matrix.arch }}
path: |
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz
src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig
retention-days: 1
build-linux:
name: Build (linux-x86_64)
needs: version
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install Tauri Linux system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
curl \
wget \
patchelf \
build-essential \
file \
rpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-unknown-linux-gnu-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached bundle artifacts
run: |
rm -rf src-tauri/target/x86_64-unknown-linux-gnu/release/bundle
- name: Set version
run: |
VERSION="${{ needs.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Build Tauri app (Linux bundles)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage
- name: Validate Linux bundles
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
)
signatures=(
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Linux build produced no AppImage, deb or rpm bundle."
exit 1
fi
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Linux build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Linux bundles
uses: actions/upload-artifact@v4
with:
name: linux-x86_64-bundles
path: |
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/rpm/*.rpm
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.sig
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz
src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage.tar.gz.sig
if-no-files-found: error
retention-days: 1
build-windows:
name: Build (windows-x86_64)
needs: version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~\.cargo\registry
~\.cargo\git
src-tauri\target
key: ${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Cache Tauri Windows tools
uses: actions/cache@v4
with:
path: ~\AppData\Local\tauri
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
- name: Prefetch Tauri NSIS toolchain
shell: pwsh
run: ./.github/scripts/prefetch-tauri-nsis.ps1
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Clear cached Windows bundle artifacts
shell: pwsh
run: |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "src-tauri/target/x86_64-pc-windows-msvc/release/bundle"
- name: Set version
shell: pwsh
run: |
$version = "${{ needs.version.outputs.version }}"
$tauri = Get-Content "src-tauri/tauri.conf.json" | ConvertFrom-Json
$tauri.version = $version
$tauri | ConvertTo-Json -Depth 100 | Set-Content "src-tauri/tauri.conf.json"
(Get-Content "src-tauri/Cargo.toml") -replace '^version = ".*"$', "version = `"$version`"" | Set-Content "src-tauri/Cargo.toml"
- name: Validate Windows release env
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
for name in TAURI_SIGNING_PRIVATE_KEY TAURI_KEY_PASSWORD; do
if [ -z "${!name}" ]; then
echo "::error::$name is required to build signed Windows updater artifacts."
exit 1
fi
done
- name: Build Tauri app (Windows bundles)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
VITE_SENTRY_RELEASE: ${{ needs.version.outputs.version }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
run: |
pnpm tauri build --target x86_64-pc-windows-msvc --bundles nsis
- name: Validate Windows bundles
shell: bash
run: |
shopt -s nullglob
installers=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
)
signatures=(
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.nsis.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.zip.sig
)
if [ ${#installers[@]} -eq 0 ]; then
echo "::error::Windows build produced no installable NSIS or MSI bundle."
exit 1
fi
for installer in "${installers[@]}"; do
if [[ "$(basename "$installer")" != *"${{ needs.version.outputs.version }}"* ]]; then
echo "::error::Windows build produced an installer for a different version: $(basename "$installer")"
exit 1
fi
done
if [ ${#signatures[@]} -eq 0 ]; then
echo "::error::Windows build produced no updater signature (.sig) artifact."
exit 1
fi
- name: Upload Windows bundles
uses: actions/upload-artifact@v4
with:
name: windows-x86_64-bundles
path: |
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis/*.zip.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi.sig
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip
src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/*.zip.sig
if-no-files-found: error
retention-days: 1
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release
# No lipo/re-signing — use the per-arch artifacts directly
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (alpha)
needs: [version, build, build-linux, build-windows]
needs: [version, build-artifacts]
runs-on: ubuntu-latest
permissions:
contents: write
pages: write
steps:
- uses: actions/checkout@v4
with:
@@ -714,7 +294,7 @@ jobs:
echo "alpha-latest.json:"; cat alpha-latest.json
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
with:
tag_name: ${{ needs.version.outputs.tag }}
name: Tolaria ${{ needs.version.outputs.display_version }}
@@ -755,46 +335,77 @@ jobs:
alpha-latest.json
# ─────────────────────────────────────────────────────────────
# Phase 4: Update GitHub Pages with release history
# Phase 4: Update GitHub Pages with docs, release history, and download assets
# ─────────────────────────────────────────────────────────────
pages:
name: Update release history page
name: Update docs and release pages
needs: [version, release]
runs-on: ubuntu-latest
permissions:
contents: write
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
concurrency:
group: github-pages
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: latest
- name: Build release history page
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs and release pages
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site/alpha _site/stable
VITEPRESS_BASE="/" pnpm docs:build
mkdir -p _site/alpha _site/stable _site/release-notes
cp -R site/.vitepress/dist/. _site/
if [ -d release-notes ]; then cp release-notes/*.md _site/release-notes/ 2>/dev/null || true; fi
gh api -H "Accept: application/vnd.github.html+json" repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
STABLE_TAG=$(gh release list --repo ${{ github.repository }} --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // ""')
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
if [ -n "$STABLE_TAG" ]; then
gh release download --repo ${{ github.repository }} "$STABLE_TAG" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
else
echo '{}' > _site/stable/latest.json
fi
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/index.html
bun scripts/build-release-history-page.ts --releases-json _site/releases.json --output-file _site/releases/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./_site
commit_message: "Update release history for ${{ needs.version.outputs.tag }}"
path: ./_site
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

8
.gitignore vendored
View File

@@ -10,6 +10,9 @@ lerna-debug.log*
node_modules
dist
dist-ssr
site/.vitepress/cache/
site/.vitepress/dist/
_site/
*.local
# Editor directories and files
@@ -42,7 +45,7 @@ final_selection.py
src-tauri/target
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
src-tauri/resources/
src-tauri/resources/mcp-server/
# Python cache
__pycache__/
@@ -73,3 +76,6 @@ CODE-HEALTH-REPORT.md
.env
.env.local
.env.*.local
# Local Codacy CLI runtime/config generated by the MCP server
.codacy/

View File

@@ -28,16 +28,11 @@ require_main_branch() {
fi
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
case "$CURRENT_BRANCH" in
main|codex/mobile)
return 0
;;
*)
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
echo " Merge or cherry-pick your work onto main, then commit there."
exit 1
;;
esac
if [ "$CURRENT_BRANCH" != "main" ]; then
echo "❌ Commits must happen on main. Current branch: $CURRENT_BRANCH"
echo " Merge or cherry-pick your work onto main, then commit there."
exit 1
fi
}
require_main_branch
@@ -45,8 +40,36 @@ ensure_node_tooling
echo "🔍 Pre-commit checks..."
STAGED_FILES=$(git diff --cached --name-only)
APP_CHANGED=false
SITE_CHANGED=false
for FILE in $STAGED_FILES; do
case "$FILE" in
site/*)
SITE_CHANGED=true
;;
.github/workflows/*|.husky/*|docs/*|*.md)
;;
*)
APP_CHANGED=true
;;
esac
done
if [ "$APP_CHANGED" = false ]; then
if [ "$SITE_CHANGED" = true ]; then
echo " → docs build..."
pnpm docs:build
else
echo " → app checks skipped (docs/workflow/hooks only)"
fi
echo "✅ Pre-commit passed"
exit 0
fi
# Lint + types (only if TS files staged)
STAGED_TS=$(git diff --cached --name-only | grep -E '\.(ts|tsx)$' || true)
STAGED_TS=$(echo "$STAGED_FILES" | grep -E '\.(ts|tsx)$' || true)
if [ -n "$STAGED_TS" ]; then
echo " → lint + tsc..."
pnpm lint --quiet
@@ -55,7 +78,7 @@ fi
# Unit tests
echo " → tests..."
pnpm test -- --silent
pnpm exec vitest run --silent
echo "✅ Pre-commit passed"

View File

@@ -28,6 +28,23 @@
# ─────────────────────────────────────────────────────────────────────────
set -e
ensure_cargo_tooling() {
if command -v cargo >/dev/null 2>&1; then
return 0
fi
if [ -s "$HOME/.cargo/env" ]; then
# shellcheck disable=SC1091
. "$HOME/.cargo/env"
fi
if ! command -v cargo >/dev/null 2>&1; then
echo "❌ cargo must be available before pushing"
echo " Install Rust via https://rustup.rs or ensure ~/.cargo/bin is in PATH."
exit 1
fi
}
ensure_node_tooling() {
if command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1; then
return 0
@@ -85,6 +102,7 @@ else
fi
require_main_push
ensure_node_tooling
ensure_cargo_tooling
START_TIME=$(date +%s)
@@ -95,47 +113,83 @@ echo "================================================"
# ── Detect what changed ─────────────────────────────────────────────────
PUSH_TARGET=$(git rev-parse @{push} 2>/dev/null || echo "")
RUST_CHANGED=true
APP_CHANGED=true
SITE_CHANGED=false
if [ -n "$PUSH_TARGET" ]; then
CHANGED=$(git diff --name-only "$PUSH_TARGET"..HEAD)
APP_CHANGED=false
if ! echo "$CHANGED" | grep -qE '^(src-tauri/|Cargo)'; then
RUST_CHANGED=false
fi
for FILE in $CHANGED; do
case "$FILE" in
site/*)
SITE_CHANGED=true
;;
.github/workflows/*|.husky/*|docs/*|*.md)
;;
*)
APP_CHANGED=true
;;
esac
done
fi
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
if [ "$APP_CHANGED" = false ]; then
if [ "$SITE_CHANGED" = true ]; then
echo ""
echo "📚 Docs-only push detected; running docs build..."
pnpm docs:build
echo " ✅ Docs build OK"
else
echo ""
echo "⏭️ App checks skipped (docs/workflow/hooks only)"
fi
ELAPSED=$(($(date +%s) - START_TIME))
echo ""
echo "✅ Pre-push passed in ${ELAPSED}s"
exit 0
fi
# ── 0. Frontend lint ───────────────────────────────────────────────────
echo ""
echo "📦 [0/5] TypeScript + Vite build..."
pnpm exec tsc --noEmit
echo "🔎 [0/6] Frontend lint..."
pnpm lint
echo " ✅ Lint OK"
# ── 1. TypeScript + Vite build ──────────────────────────────────────────
echo ""
echo "📦 [1/6] TypeScript + Vite build..."
pnpm build
echo " ✅ Build OK"
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
# ── 2. Frontend coverage (≥70%) — includes all unit tests ───────────────
echo ""
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
echo "📊 [2/6] Frontend tests + coverage (≥70%)..."
pnpm test:coverage --silent
echo " ✅ Frontend coverage OK"
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
# ── 3. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
echo ""
if [ "$RUST_CHANGED" = true ]; then
echo "🔧 [2/5] Clippy + rustfmt..."
echo "🔧 [3/6] Clippy + rustfmt..."
cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
echo " ✅ Rust lint OK"
else
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
echo "⏭️ [3/6] Rust lint — skipped (no src-tauri/ changes)"
fi
# ── 3. Rust coverage (≥85% lines) ──────────────────────────────────────
# ── 4. Rust coverage (≥85% lines) ──────────────────────────────────────
echo ""
if [ "$RUST_CHANGED" = true ]; then
LLVM_COV_FLAGS="--no-clean"
if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then
LLVM_COV_FLAGS=""
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
echo "🦀 [4/6] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
else
echo "🦀 [3/5] Rust coverage (≥85%, incremental)..."
echo "🦀 [4/6] Rust coverage (≥85%, incremental)..."
fi
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
@@ -148,24 +202,24 @@ if [ "$RUST_CHANGED" = true ]; then
-- --test-threads=1
echo " ✅ Rust coverage OK"
else
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
echo "⏭️ [4/6] Rust coverage — skipped (no src-tauri/ changes)"
fi
# ── 4. Playwright core smoke lane (if any exist) ──────────────────────
# ── 5. Playwright core smoke lane (if any exist) ──────────────────────
echo ""
SMOKE_FILES=$(find tests/smoke tests/integration -name '*.spec.ts' 2>/dev/null | head -1)
if [ -n "$SMOKE_FILES" ]; then
echo "🎭 [4/5] Playwright core smoke tests..."
echo "🎭 [5/6] Playwright core smoke tests..."
if ! pnpm playwright:smoke; then
echo " ❌ Core smoke tests FAILED"
exit 1
fi
echo " ✅ Core smoke tests OK"
else
echo "⏭️ [4/5] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
echo "⏭️ [5/6] Playwright core smoke tests — skipped (no tests/**/*.spec.ts)"
fi
# ── 5. CodeScene code health gate (ratchet) ──────────────────────────────
# ── 6. CodeScene code health gate (ratchet) ──────────────────────────────
# Thresholds live in .codescene-thresholds and only ever go UP (ratchet).
# If remote scores improved, the hook updates the file and stops so the new
# floor is committed with normal verified hooks before the next push.
@@ -181,7 +235,7 @@ if [ -f "$THRESHOLDS_FILE" ]; then
fi
echo ""
echo "🏥 [5/5] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
echo "🏥 [6/6] CodeScene code health (Hotspot ≥${HOTSPOT_MIN} + Average ≥${AVERAGE_MIN})..."
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
else

10
.semgrepignore Normal file
View File

@@ -0,0 +1,10 @@
tests/
e2e/
node_modules/
dist/
coverage/
test-results/
src-tauri/target/
target/
src-tauri/gen/apple/assets/mcp-server/index.js
src-tauri/gen/apple/assets/mcp-server/ws-bridge.js

121
AGENTS.md
View File

@@ -1,67 +1,22 @@
# AGENTS.md — Tolaria App
> Quick links: [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
## 1. Development Process
---
## 1. Task Workflow
### 1a. Pick up a task
Run `/laputa-next-task` — fetches next task (To Rework first, then Open), moves to In Progress, returns full description.
### Start working on a task
**Before writing a single line of code:** run `mcp__codescene__code_health_score` to check the current codebase health against `.codescene-thresholds`. If the score is already below the threshold, **stop and refactor first** — find the worst files with the MCP, improve them, commit, then start the task. Never start feature work on a codebase that is already below the gate.
- Read task description and all comments fully
- For To Rework: the ❌ QA failed comment tells you exactly what to fix
- Check `docs/adr/` for relevant architecture decisions before structural choices
- Add a comment: `🚀 Starting work on this task. [Brief description of approach]`
### 1b. Implement
- Work on `main` branch — **no branches, no PRs, ever**. Pre-commit and pre-push block work from any other branch.
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- **⛔ NEVER use --no-verify**
- For UI tasks: open `ui-design.pen` first, study visual language, design in light mode. You don't need Pencil to use it you can open it as a JSON file.
### 1c. When done
**Phase 1 — Playwright (only for core user flows):**
Write Playwright test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. The curated `pnpm playwright:smoke` suite must stay under **5 minutes**; use `pnpm playwright:regression` for the full Playwright pass.
```bash
pnpm dev --port 5201 &
sleep 3
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```
**Phase 2 — Native app QA:**
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
After both phases pass, add a **completion comment** to the Todoist task. The comment must include:
- What was implemented (a few lines covering logic and UX/UI)
- QA: what was tested and how (Playwright / native screenshot / osascript)
- Refactoring: any files refactored to meet the CodeScene gate (or "none needed")
- ADRs: any new/updated ADRs (or "none")
- Docs: any updated docs (ARCHITECTURE.md, ABSTRACTIONS.md, etc.) (or "none")
- Code health: final Hotspot and Average scores after push
---
## 2. Development Process
- Check `docs/ARCHITECTURE.md` and `docs/ABSTRACTIONS.md` for relevant structural information
- For UI tasks: study app visual language and components first. Prioritize reusing existing components, assets, and variables over recreating them.
- If working on a Todoist task, add a comment: `🚀 Starting work on this task. [Brief description of approach]`
### Commits & pushes
- Push directly to `main` — no PRs, no branches. Pre-push blocks non-`main` pushes.
- Commit every 2030 min: `feat:`, `fix:`, `refactor:`, `test:`, `docs:`
- Pre-push hook runs full check suite (build + tests + core Playwright smoke + CodeScene)
- **A task is NOT done until `git push origin main` succeeds.** If the hook blocks: read the error, fix it (clippy, tests, CodeScene, build), commit the fix, push again. **⛔ NEVER use --no-verify**
@@ -91,6 +46,8 @@ When adding or changing a meaningful user-facing feature, include the event name
Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code Health** ≥ thresholds in `.codescene-thresholds`. Both gates block commit/push. Thresholds are a **ratchet** — only go up. When pre-push sees improved remote scores, it updates `.codescene-thresholds`, stages it, and stops so you can commit the new floor with normal verified hooks before pushing again. Never add `// eslint-disable`, `#[allow(...)]`, or `as any`.
**Release rule:** CodeScene is a before/after gate, not just a final score. Every task must record the starting CodeScene state before edits and the final state after edits. If touched code gets worse, refactor before committing.
**⛔ NEVER edit `.codescene-thresholds` to lower the values.** If the gate blocks you, improve the code — do not lower the bar.
**CodeScene access order:** use CodeScene MCP tools if available. If MCP is unavailable, use the installed `cs` CLI for file-level review/delta work, and use the CodeScene API (`CODESCENE_PAT` + `CODESCENE_PROJECT_ID`) for project-wide Hotspot/Average threshold checks from `.codescene-thresholds`.
@@ -103,12 +60,70 @@ Pre-commit and pre-push hooks enforce **Hotspot Code Health** and **Average Code
**If CodeScene gate blocks your push:** use `mcp__codescene__code_health_score` to find the worst file, refactor it, commit, push again. Do NOT stop or wait for laputa-refactor — that is a background loop, not a substitute for fixing your own regressions.
### Security scan with Codacy (mandatory)
Use Codacy as a security and static-analysis gate before a task is considered releasable.
- Prefer the Codacy MCP inside Codex to inspect repository/file issues for every touched code file.
- If MCP is unavailable, use the local CLI wrapper, e.g. `.codacy/cli.sh analyze <path> --format sarif`; choose the relevant tool when useful (`eslint`, `opengrep`, `trivy`, `lizard`).
- **Always fix Critical and High severity findings introduced by your change.** Do not move the task to In Review with new Critical/High Codacy issues.
- Review Medium findings. Fix them when they are real defects or security-sensitive; otherwise explain why they are acceptable in the completion comment.
- Never silence a Codacy rule just to pass the scan. Prefer small code changes that remove the finding.
### Check suite (runs on every push)
```bash
pnpm lint && npx tsc --noEmit && pnpm test && pnpm test:coverage # frontend ≥70%
cargo test && cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
```
Coverage is a release gate, not a vanity metric:
- Frontend coverage must stay ≥70%.
- Rust line coverage must stay ≥85%.
- For bug fixes, add a regression test when practical.
- For new behavior, add targeted coverage close to the changed code; do not rely only on broad E2E coverage.
### UI and native QA
**Phase 1 — Playwright (only for core user flows):**
Write Playwright test in `tests/smoke/<slug>.spec.ts` only if feature touches: vault open, note create/save/delete, search, wikilink navigation, git commit/push, conflict resolution. Tag a test with `@smoke` only if it protects a core pre-push workflow. Do NOT tag cosmetic or mock-heavy checks — keep those in the full regression lane. The curated `pnpm playwright:smoke` suite must stay under **5 minutes**; use `pnpm playwright:regression` for the full Playwright pass.
```bash
pnpm dev --port 5201 &
sleep 3
BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```
**Phase 2 — Native app QA:**
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use computer-use/browser-control style interaction for native UI QA when available: click, hover, drag, select, scroll, and type the way a real user would with the mouse and trackpad. For every UI feature, test the primary mouse-driven path first, then verify any relevant keyboard shortcut or keyboard-first workflow still works. Tolaria is still a keyboard-first app, but QA must not assume users only interact by keyboard.
Use `osascript` for app focus, keyboard shortcuts, and keyboard-specific checks. **⚠️ WKWebView:** `osascript keystroke` can be blocked inside editor content — use computer use for native editor interaction when possible, and rely on Playwright for deterministic text-input coverage. Write result as Todoist comment (✅ or ❌).
### Release-readiness checklist
Before pushing or moving a task to In Review, verify the release gates and add a **completion comment** to the Todoist task. The comment must include:
- What was implemented (a few lines covering logic and UX/UI).
- QA: what was tested and how (Playwright / native screenshot / osascript).
- Tests/coverage: commands run and final coverage result.
- CodeScene: before/after touched-file checks plus final Hotspot and Average scores after push; final scores must pass `.codescene-thresholds`.
- Coverage commands passed (`pnpm test:coverage` and `cargo llvm-cov ... --fail-under-lines 85`) or the change is docs-only.
- Codacy: MCP/CLI scan summary; confirm no new Critical/High findings.
- Localization: any user-facing copy lives in `src/lib/locales/en.json`, `pnpm l10n:translate` was run, and `pnpm l10n:validate` passes. If no copy changed, say “Localization: no UI copy changes”.
- PostHog: meaningful new user actions/events are instrumented with safe metadata; noisy/minor changes explicitly say “PostHog: no event needed because …”.
- Refactoring: any files refactored to meet the CodeScene gate, or "none needed".
- ADRs: any new/updated ADRs, or "none".
- Docs: any updated docs (`ARCHITECTURE.md`, `ABSTRACTIONS.md`, etc.), or "none".
- Demo vault dirt checked: `git status --short -- demo-vault demo-vault-v2` is empty unless fixture changes are intentional.
### ADRs & docs
ADRs live in `docs/adr/`. Create in the same commit as the code. Never edit existing — create a new one that supersedes. Use `/create-adr`. **When:** new dependency, storage strategy, platform target, core abstraction, cross-cutting pattern. **Not for:** bug fixes, styling, refactors.
@@ -117,7 +132,7 @@ After any Tauri command, new component/hook, data model change, or new integrati
---
## 3. Product Rules
## 2. Product Rules
### Demo vault hygiene (`demo-vault/`, `demo-vault-v2/`)
@@ -157,7 +172,7 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
---
## 4. Reference
## 3. Reference
### macOS / Tauri gotchas

View File

@@ -1,3 +1,8 @@
---
type: Note
_organized: true
---
@AGENTS.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
This file is only a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.

8
GEMINI.md Normal file
View File

@@ -0,0 +1,8 @@
---
type: Note
_organized: true
---
@AGENTS.md
This file is only a Gemini CLI compatibility shim. Keep shared agent instructions in `AGENTS.md`.

View File

@@ -1,4 +1,4 @@
![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)
![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) [![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
@@ -43,12 +43,14 @@ brew install --cask tolaria
### Download from releases
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux.
Download the [latest release here](https://refactoringhq.github.io/tolaria/download/) for macOS, Windows, or Linux. Windows installers are Authenticode-signed; company-managed devices may still require IT approval of the Tolaria publisher before first install.
## Getting started
When you open Tolaria for the first time you get the chance of cloning the [getting started vault](https://github.com/refactoringhq/tolaria-getting-started) — which gives you a walkthrough of the whole app.
The public user docs live in [`site/`](site/) and are published to GitHub Pages. Start with [Install Tolaria](site/start/install.md), then [First Launch](site/start/first-launch.md).
## Open source and local setup
Tolaria is open source and built with Tauri, React, and TypeScript. If you want to run or contribute to the app locally, here is [how to get started](https://github.com/refactoringhq/tolaria/blob/main/docs/GETTING-STARTED.md). You can also find the gist below 👇

View File

@@ -16,7 +16,7 @@ We currently support security fixes for:
## Reporting a vulnerability
Please email **luca@refactoring.club** with the subject line **`[Tolaria Security]`**.
Please use GitHub's private vulnerability reporting flow for this repository.
Include as much of the following as you can:

View File

@@ -1,41 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

View File

@@ -1,19 +0,0 @@
import { StatusBar } from 'expo-status-bar'
import { StyleSheet } from 'react-native'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { MobileApp } from './src/MobileApp'
export default function App() {
return (
<GestureHandlerRootView style={styles.root}>
<MobileApp />
<StatusBar style="auto" />
</GestureHandlerRootView>
)
}
const styles = StyleSheet.create({
root: {
flex: 1,
},
})

View File

@@ -1,37 +0,0 @@
{
"expo": {
"name": "Tolaria",
"slug": "tolaria-mobile",
"scheme": "tolaria",
"version": "0.1.0",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"bundleIdentifier": "com.tolaria.mobile.dev",
"supportsTablet": true
},
"android": {
"package": "com.tolaria.mobile.dev",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-secure-store",
"expo-web-browser"
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,9 +0,0 @@
import 'react-native-gesture-handler'
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

View File

@@ -1,50 +0,0 @@
const path = require('node:path')
const { getDefaultConfig } = require('expo/metro-config')
const projectRoot = __dirname
const workspaceRoot = path.resolve(projectRoot, '../..')
const config = getDefaultConfig(projectRoot)
const mobileNodeModules = path.resolve(projectRoot, 'node_modules')
const workspaceNodeModules = path.resolve(workspaceRoot, 'node_modules')
const mobileReactRoot = path.resolve(mobileNodeModules, 'react')
const mobileReactDomRoot = path.resolve(mobileNodeModules, 'react-dom')
config.watchFolders = [workspaceRoot]
config.resolver.nodeModulesPaths = [
mobileNodeModules,
workspaceNodeModules,
]
config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules,
react: mobileReactRoot,
'react-dom': mobileReactDomRoot,
'react-native': path.resolve(workspaceNodeModules, 'react-native'),
}
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (moduleName === 'react' || moduleName.startsWith('react/')) {
return context.resolveRequest(
{ ...context, originModulePath: path.join(mobileReactRoot, 'index.js') },
moduleName,
platform,
)
}
if (moduleName === 'react-dom' || moduleName.startsWith('react-dom/')) {
return context.resolveRequest(
{ ...context, originModulePath: path.join(mobileReactDomRoot, 'index.js') },
moduleName,
platform,
)
}
if (moduleName === 'isomorphic-git') {
return {
filePath: path.resolve(workspaceNodeModules, 'isomorphic-git/index.js'),
type: 'sourceFile',
}
}
return context.resolveRequest(context, moduleName, platform)
}
module.exports = config

View File

@@ -1,44 +0,0 @@
{
"name": "@tolaria/mobile",
"version": "0.1.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"start:dev-client": "expo start --dev-client",
"android": "expo start --android",
"ios": "expo start --ios",
"ios:dev-client": "expo run:ios --port 8091",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"web": "expo start --web"
},
"dependencies": {
"@10play/tentap-editor": "^1.0.1",
"@tolaria/markdown": "workspace:*",
"buffer": "^6.0.3",
"expo": "~54.0.34",
"expo-auth-session": "~7.0.11",
"expo-dev-client": "~6.0.21",
"expo-file-system": "19.0.22",
"expo-modules-core": "3.0.30",
"expo-secure-store": "~15.0.8",
"expo-status-bar": "~3.0.9",
"expo-web-browser": "~15.0.11",
"isomorphic-git": "^1.37.6",
"phosphor-react-native": "^3.0.6",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "^5.6.2",
"react-native-svg": "^15.12.1",
"react-native-webview": "13.15.0"
},
"devDependencies": {
"@types/react": "~19.1.17",
"@types/react-dom": "~19.1.11",
"typescript": "~5.9.3",
"vitest": "^4.0.18"
},
"private": true
}

View File

@@ -1,148 +0,0 @@
import { CaretLeft, GearSix, PaperPlaneTilt, Robot } from 'phosphor-react-native'
import { useState } from 'react'
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
import type { MobileNote } from './mobileNoteProjection'
import type { MobileAiProvider } from './mobileAiSettings'
import { styles } from './styles'
import { colors } from './theme'
export function MobileAiPanel({
note,
onClose,
onOpenSettings,
onSendPrompt,
provider,
}: {
note: MobileNote
onClose?: () => void
onOpenSettings: () => void
onSendPrompt: (prompt: string, provider: MobileAiProvider) => Promise<string>
provider: MobileAiProvider | null
}) {
const [failed, setFailed] = useState(false)
const [isSending, setIsSending] = useState(false)
const [prompt, setPrompt] = useState('')
const [response, setResponse] = useState('')
const sendPrompt = () => {
if (!provider || prompt.trim().length === 0) return
setFailed(false)
setIsSending(true)
void onSendPrompt(prompt, provider)
.then(setResponse)
.catch(() => setFailed(true))
.finally(() => setIsSending(false))
}
return (
<View style={styles.properties}>
<AiToolbar onClose={onClose} onOpenSettings={onOpenSettings} />
{provider ? (
<AiChatSurface
failed={failed}
isSending={isSending}
note={note}
onChangePrompt={setPrompt}
onSend={sendPrompt}
prompt={prompt}
provider={provider}
response={response}
/>
) : (
<AiEmptyState onOpenSettings={onOpenSettings} />
)}
</View>
)
}
function AiToolbar({
onClose,
onOpenSettings,
}: {
onClose?: () => void
onOpenSettings: () => void
}) {
return (
<View style={styles.toolbar}>
<Text style={styles.propertiesTitle}>AI</Text>
<View style={styles.toolbarSpacer} />
<Pressable onPress={onOpenSettings} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<GearSix size={23} color={colors.textSoft} />
</Pressable>
{onClose ? (
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<CaretLeft size={23} color={colors.textSoft} />
</Pressable>
) : null}
</View>
)
}
function AiEmptyState({ onOpenSettings }: { onOpenSettings: () => void }) {
return (
<View style={styles.aiEmptyState}>
<Robot color={colors.iconMuted} size={26} />
<Text style={styles.aiEmptyTitle}>No API model configured</Text>
<Text style={styles.aiEmptyDescription}>Add an API model in Settings before using the AI panel.</Text>
<Pressable onPress={onOpenSettings} style={({ pressed }) => [styles.aiSettingsButton, pressed ? styles.pressed : null]}>
<GearSix color="#ffffff" size={17} />
<Text style={styles.aiSettingsButtonText}>Open Settings</Text>
</Pressable>
</View>
)
}
function AiChatSurface({
failed,
isSending,
note,
onChangePrompt,
onSend,
prompt,
provider,
response,
}: {
failed: boolean
isSending: boolean
note: MobileNote
onChangePrompt: (value: string) => void
onSend: () => void
prompt: string
provider: MobileAiProvider
response: string
}) {
const canSend = prompt.trim().length > 0 && !isSending
return (
<ScrollView contentContainerStyle={styles.aiContent}>
<View style={styles.aiContextCard}>
<Text style={styles.aiContextTitle}>{provider.name} · {provider.modelId}</Text>
<Text numberOfLines={2} style={styles.aiContextDetail}>{note.title}</Text>
</View>
{response ? <Text style={styles.aiResponse}>{response}</Text> : <Text style={styles.aiEmptyDescription}>Ask about the active note. API models run in chat mode only.</Text>}
{failed ? <Text style={styles.propertyError}>AI request failed.</Text> : null}
<TextInput
multiline
onChangeText={onChangePrompt}
placeholder={`Ask about ${note.title}`}
placeholderTextColor={colors.mutedText}
style={styles.aiPrompt}
textAlignVertical="top"
value={prompt}
/>
<Pressable
disabled={!canSend}
onPress={onSend}
style={({ pressed }) => [
styles.aiSendButton,
!canSend ? styles.composeButtonDisabled : null,
pressed ? styles.pressed : null,
]}
>
<PaperPlaneTilt color="#ffffff" size={18} weight="fill" />
<Text style={styles.aiSendButtonText}>{isSending ? 'Sending' : 'Send'}</Text>
</Pressable>
</ScrollView>
)
}

View File

@@ -1,197 +0,0 @@
import { CaretLeft, Trash } from 'phosphor-react-native'
import { useState } from 'react'
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
import {
mobileAiProviderPresets,
type MobileAiProvider,
type MobileAiProviderDraft,
type MobileAiProviderKind,
type MobileAiSettings,
} from './mobileAiSettings'
import { styles } from './styles'
import { colors } from './theme'
const providerKinds: MobileAiProviderKind[] = ['open_ai', 'anthropic', 'gemini', 'open_router', 'open_ai_compatible']
export function MobileAiSettingsPanel({
failed,
isSaving,
onAddProvider,
onClose,
onRemoveProvider,
settings,
}: {
failed: boolean
isSaving: boolean
onAddProvider: (draft: MobileAiProviderDraft) => Promise<boolean>
onClose?: () => void
onRemoveProvider: (providerId: string) => Promise<boolean>
settings: MobileAiSettings
}) {
return (
<View style={styles.properties}>
<SettingsToolbar onClose={onClose} />
<ScrollView contentContainerStyle={styles.aiSettingsContent}>
<Text style={styles.aiSettingsSectionTitle}>API models</Text>
<Text style={styles.aiSettingsDescription}>API keys are saved locally on this device and are not written to vault settings.</Text>
<ProviderList providers={settings.providers} onRemoveProvider={onRemoveProvider} />
<ProviderDraftForm failed={failed} isSaving={isSaving} onAddProvider={onAddProvider} />
</ScrollView>
</View>
)
}
function ProviderDraftForm({
failed,
isSaving,
onAddProvider,
}: {
failed: boolean
isSaving: boolean
onAddProvider: (draft: MobileAiProviderDraft) => Promise<boolean>
}) {
const [draft, setDraft] = useState<MobileAiProviderDraft>(() => initialDraft('open_ai'))
const canSave = isProviderDraftReady(draft)
const isDisabled = !canSave || isSaving
const submitProvider = () => {
if (isDisabled) return
void onAddProvider(draft).then((saved) => {
if (saved) {
setDraft(initialDraft(draft.kind))
}
})
}
return (
<>
<ProviderKindPicker value={draft.kind} onChange={(kind) => setDraft(initialDraft(kind))} />
<TextInput
onChangeText={(modelId) => setDraft((current) => ({ ...current, modelId }))}
placeholder={mobileAiProviderPresets[draft.kind].placeholder}
placeholderTextColor={colors.mutedText}
style={styles.aiInput}
value={draft.modelId}
/>
<TextInput
onChangeText={(apiKey) => setDraft((current) => ({ ...current, apiKey }))}
placeholder="API key"
placeholderTextColor={colors.mutedText}
secureTextEntry
style={styles.aiInput}
value={draft.apiKey}
/>
<ProviderSaveError failed={failed} />
<ProviderSaveButton disabled={isDisabled} isSaving={isSaving} onPress={submitProvider} />
</>
)
}
function ProviderSaveError({ failed }: { failed: boolean }) {
return failed ? <Text style={styles.propertyError}>Could not save AI settings.</Text> : null
}
function ProviderSaveButton({
disabled,
isSaving,
onPress,
}: {
disabled: boolean
isSaving: boolean
onPress: () => void
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.aiSettingsButton,
disabled ? styles.composeButtonDisabled : null,
pressed ? styles.pressed : null,
]}
>
<Text style={styles.aiSettingsButtonText}>{isSaving ? 'Saving' : 'Add API model'}</Text>
</Pressable>
)
}
function isProviderDraftReady(draft: MobileAiProviderDraft) {
return draft.modelId.trim().length > 0 && draft.apiKey.trim().length > 0
}
function SettingsToolbar({ onClose }: { onClose?: () => void }) {
return (
<View style={styles.toolbar}>
<Text style={styles.propertiesTitle}>Settings</Text>
<View style={styles.toolbarSpacer} />
{onClose ? (
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<CaretLeft size={23} color={colors.textSoft} />
</Pressable>
) : null}
</View>
)
}
function ProviderList({
onRemoveProvider,
providers,
}: {
onRemoveProvider: (providerId: string) => void
providers: MobileAiProvider[]
}) {
if (providers.length === 0) {
return <Text style={styles.aiSettingsEmpty}>No API models configured.</Text>
}
return (
<View style={styles.aiProviderList}>
{providers.map((provider) => (
<View key={provider.id} style={styles.aiProviderRow}>
<View style={styles.aiProviderText}>
<Text style={styles.aiProviderTitle}>{provider.name}</Text>
<Text numberOfLines={1} style={styles.aiProviderDetail}>{provider.modelId}</Text>
</View>
<Pressable onPress={() => onRemoveProvider(provider.id)} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<Trash size={18} color={colors.textSoft} />
</Pressable>
</View>
))}
</View>
)
}
function ProviderKindPicker({
onChange,
value,
}: {
onChange: (value: MobileAiProviderKind) => void
value: MobileAiProviderKind
}) {
return (
<View style={styles.aiProviderKindRow}>
{providerKinds.map((kind) => (
<Pressable
key={kind}
onPress={() => onChange(kind)}
style={({ pressed }) => [
styles.aiProviderKindChip,
value === kind ? styles.aiProviderKindChipSelected : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.aiProviderKindText, value === kind ? styles.aiProviderKindTextSelected : null]}>{mobileAiProviderPresets[kind].name}</Text>
</Pressable>
))}
</View>
)
}
function initialDraft(kind: MobileAiProviderKind): MobileAiProviderDraft {
return {
apiKey: '',
kind,
modelId: '',
name: mobileAiProviderPresets[kind].name,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,482 +0,0 @@
import { CaretDown, CaretRight } from 'phosphor-react-native'
import { useState, type ReactNode } from 'react'
import { Pressable, Text, TextInput, View, type StyleProp, type ViewStyle } from 'react-native'
import type { MobileNote } from './demoData'
import { NamedIcon, type IconName } from './NamedIcon'
import {
formatMobileNoteTags,
isMobileNotePropertySelected,
mobileNoteIconOptions,
mobileNoteStatusOptions,
mobileNoteTagOptions,
mobileNoteTypeOptions,
parseMobileNoteTags,
toggleMobileNoteTag,
type MobileNotePropertyPatch,
} from './mobileNoteProperties'
import { filterMobilePropertyComboOptions, resolveMobilePropertyComboValue } from './mobilePropertyCombo'
import { mobilePropertyDisplayValue, type MobilePropertyPickerKey } from './mobilePropertyPicker'
import { styles } from './styles'
import { colors } from './theme'
export function MobileEditablePropertyPickers({
disabled,
note,
onChangeProperties,
onSelectPicker,
openPicker,
}: {
disabled: boolean
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onSelectPicker: (selected: MobilePropertyPickerKey) => void
openPicker: MobilePropertyPickerKey | null
}) {
const today = formatMobilePropertyDate(new Date())
return (
<>
<EditableTextProperty
disabled={disabled}
isOpen={openPicker === 'type'}
key={`type:${note.id}:${note.type}`}
label="Type"
placeholder="Type"
suggestions={mobileNoteTypeOptions}
value={note.type}
onCommit={(type) => onChangeProperties?.({ type })}
onOpen={() => onSelectPicker('type')}
variant="combo"
/>
<EditableTextProperty
disabled={disabled}
isOpen={openPicker === 'status'}
key={`status:${note.id}:${note.status ?? ''}`}
label="Status"
placeholder="Status"
suggestions={mobileNoteStatusOptions}
value={note.status ?? ''}
onCommit={(status) => onChangeProperties?.({ status })}
onOpen={() => onSelectPicker('status')}
/>
<EditableTextProperty
disabled={disabled}
isOpen={openPicker === 'date'}
key={`date:${note.id}:${note.date}`}
label="Date"
placeholder="Date"
suggestions={[today, '']}
value={note.date}
onCommit={(date) => onChangeProperties?.({ date })}
onOpen={() => onSelectPicker('date')}
/>
<IconProperty
disabled={disabled}
isOpen={openPicker === 'icon'}
note={note}
onChangeProperties={onChangeProperties}
onOpen={() => onSelectPicker('icon')}
/>
<TagsProperty
disabled={disabled}
isOpen={openPicker === 'tags'}
key={`tags:${note.id}:${formatMobileNoteTags(note.tags)}`}
note={note}
onChangeProperties={onChangeProperties}
onOpen={() => onSelectPicker('tags')}
/>
</>
)
}
function EditableTextProperty({
disabled,
isOpen,
label,
onCommit,
onOpen,
placeholder,
suggestions,
value,
variant = 'chips',
}: {
disabled: boolean
isOpen: boolean
label: string
onCommit: (value: string) => void
onOpen: () => void
placeholder: string
suggestions: readonly string[]
value: string
variant?: 'chips' | 'combo'
}) {
const [draft, setDraft] = useState(value)
const commitDraft = () => {
const next = variant === 'combo'
? resolveMobilePropertyComboValue({ options: suggestions, value: draft })
: draft
commitTextValue({ current: value, next, onCommit, onSettled: setDraft })
}
return (
<PropertyPickerSection
disabled={disabled}
isOpen={isOpen}
label={label}
value={mobilePropertyDisplayValue({ value })}
onOpen={onOpen}
>
<View style={styles.propertyPickerOptions}>
<TextInput
autoCapitalize="sentences"
autoFocus={variant === 'combo'}
editable={!disabled}
keyboardType="default"
onBlur={commitDraft}
onChangeText={setDraft}
onSubmitEditing={commitDraft}
placeholder={placeholder}
placeholderTextColor={colors.mutedText}
returnKeyType="done"
selectTextOnFocus={variant === 'combo'}
style={styles.propertyTextInput}
value={draft}
/>
{variant === 'combo'
? (
<PropertyComboOptions
disabled={disabled}
query={draft}
suggestions={suggestions}
value={value}
onSelect={(selected) => {
setDraft(selected)
onCommit(selected)
}}
/>
)
: (
<PropertyChipOptions>
{suggestions.map((option) => (
<PropertyTextChip
disabled={disabled}
key={option || 'none'}
option={option}
value={value}
onSelect={(selected) => {
setDraft(selected)
onCommit(selected)
}}
/>
))}
</PropertyChipOptions>
)}
</View>
</PropertyPickerSection>
)
}
function IconProperty({
disabled,
isOpen,
note,
onChangeProperties,
onOpen,
}: {
disabled: boolean
isOpen: boolean
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onOpen: () => void
}) {
return (
<PropertyPickerSection
disabled={disabled}
isOpen={isOpen}
label="Icon"
value={mobilePropertyDisplayValue({ value: note.icon })}
onOpen={onOpen}
>
<PropertyChipOptions>
{mobileNoteIconOptions.map((option) => (
<PropertyIconChip
disabled={disabled}
key={option}
option={option}
value={note.icon}
onSelect={(icon) => onChangeProperties?.({ icon })}
/>
))}
</PropertyChipOptions>
</PropertyPickerSection>
)
}
function TagsProperty({
disabled,
isOpen,
note,
onChangeProperties,
onOpen,
}: {
disabled: boolean
isOpen: boolean
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onOpen: () => void
}) {
const [draft, setDraft] = useState(formatMobileNoteTags(note.tags))
const commitDraft = () => onChangeProperties?.({ tags: parseMobileNoteTags(draft) })
return (
<PropertyPickerSection
disabled={disabled}
isOpen={isOpen}
label="Tags"
value={note.tags.length > 0 ? formatMobileNoteTags(note.tags) : 'None'}
onOpen={onOpen}
>
<View style={styles.propertyPickerOptions}>
<TextInput
autoCapitalize="none"
editable={!disabled}
keyboardType="default"
onBlur={commitDraft}
onChangeText={setDraft}
onSubmitEditing={commitDraft}
placeholder="Tags"
placeholderTextColor={colors.mutedText}
returnKeyType="done"
style={styles.propertyTextInput}
value={draft}
/>
<PropertyChipOptions>
{mobileNoteTagOptions.map((option) => (
<PropertyTextChip
disabled={disabled}
key={option}
option={option}
value={note.tags}
onSelect={(tag) => {
const tags = toggleMobileNoteTag(note.tags, tag)
setDraft(formatMobileNoteTags(tags))
onChangeProperties?.({ tags })
}}
/>
))}
</PropertyChipOptions>
</View>
</PropertyPickerSection>
)
}
function PropertyPickerSection({
children,
disabled,
isOpen,
label,
onOpen,
value,
}: {
children: ReactNode
disabled: boolean
isOpen: boolean
label: string
onOpen: () => void
value: string
}) {
return (
<>
<PropertyPickerRow disabled={disabled} isOpen={isOpen} label={label} value={value} onPress={onOpen} />
{isOpen ? children : null}
</>
)
}
function PropertyPickerRow({
disabled,
isOpen,
label,
onPress,
value,
}: {
disabled: boolean
isOpen: boolean
label: string
onPress: () => void
value: string
}) {
const Caret = isOpen ? CaretDown : CaretRight
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [styles.propertyRow, disabled ? styles.propertyDisabled : null, pressed ? styles.pressed : null]}
>
<Text style={styles.propertyLabel}>{label}</Text>
<Text numberOfLines={1} style={styles.propertyValue}>{value}</Text>
<Caret size={16} color={colors.textSoft} />
</Pressable>
)
}
function PropertyIconChip({
disabled,
onSelect,
option,
value,
}: {
disabled: boolean
onSelect: (option: string) => void
option: string
value: string | undefined
}) {
const isSelected = isMobileNotePropertySelected({ current: value, option })
return (
<SelectablePropertyChip
disabled={disabled}
isSelected={isSelected}
onPress={() => onSelect(option)}
style={styles.propertyIconChip}
>
<NamedIcon color={isSelected ? colors.primary : colors.textSoft} name={option as IconName} size={20} />
</SelectablePropertyChip>
)
}
function PropertyChipOptions({ children }: { children: ReactNode }) {
return (
<View style={styles.propertyChipRow}>
{children}
</View>
)
}
function PropertyComboOptions({
disabled,
onSelect,
query,
suggestions,
value,
}: {
disabled: boolean
onSelect: (option: string) => void
query: string
suggestions: readonly string[]
value: string
}) {
const options = filterMobilePropertyComboOptions({ options: suggestions, query })
return (
<View style={styles.propertyComboBox}>
{options.map((option) => {
const isSelected = isMobileNotePropertySelected({ current: value, option })
return (
<Pressable
disabled={disabled}
key={option || 'none'}
onPress={() => onSelect(option)}
style={({ pressed }) => [
styles.propertyComboOption,
isSelected ? styles.propertyComboOptionSelected : null,
disabled ? styles.propertyDisabled : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.propertyComboOptionText, isSelected ? styles.propertyComboOptionTextSelected : null]}>
{option || 'None'}
</Text>
</Pressable>
)
})}
{options.length === 0 ? <Text style={styles.propertyComboEmpty}>No matching types.</Text> : null}
</View>
)
}
function PropertyTextChip({
disabled,
onSelect,
option,
value,
}: {
disabled: boolean
onSelect: (option: string) => void
option: string
value: readonly string[] | string | undefined
}) {
const isSelected = Array.isArray(value)
? value.includes(option)
: isMobileNotePropertySelected({ current: typeof value === 'string' ? value : undefined, option })
return (
<SelectablePropertyChip
disabled={disabled}
isSelected={isSelected}
onPress={() => onSelect(option)}
style={styles.propertyChip}
>
<Text style={[styles.propertyChipText, isSelected ? styles.propertyChipTextSelected : null]}>
{option || 'None'}
</Text>
</SelectablePropertyChip>
)
}
function SelectablePropertyChip({
children,
disabled,
isSelected,
onPress,
style,
}: {
children: ReactNode
disabled: boolean
isSelected: boolean
onPress: () => void
style: StyleProp<ViewStyle>
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
style,
isSelected ? styles.propertyChipSelected : null,
disabled ? styles.propertyDisabled : null,
pressed ? styles.pressed : null,
]}
>
{children}
</Pressable>
)
}
function commitTextValue({
current,
next,
onCommit,
onSettled,
}: {
current: string
next: string
onCommit: (value: string) => void
onSettled?: (value: string) => void
}) {
const normalized = next.trim()
onSettled?.(normalized)
if (normalized !== current) {
onCommit(normalized)
}
}
function formatMobilePropertyDate(date: Date) {
return new Intl.DateTimeFormat(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(date)
}

View File

@@ -1,126 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { KeyboardAvoidingView, Platform, View } from 'react-native'
import type { WebViewMessageEvent } from 'react-native-webview'
import { RichText, Toolbar, useEditorBridge } from '@10play/tentap-editor'
import { MobileEditorWikilinkSuggestions } from './MobileEditorWikilinkSuggestions'
import { parseEditorMessage, type MobileEditorWikilinkFrame } from './mobileEditorMessages'
import type { MobileNote } from './mobileNoteProjection'
import { createMobileEditorDraft, type MobileEditorDraft } from './mobileEditorDraft'
import {
createMobileEditorDocument,
createMobileEditorHtml,
} from './mobileEditorDocument'
import { resolveMobileRelationshipNote } from './mobileRelationshipRefs'
import { mobileEditorBridgeExtensions } from './mobileWikilinkEditorBridge'
import { mobileEditorCss, mobileEditorSetupScript } from './mobileEditorWebViewSetup'
import { styles } from './styles'
export function MobileEditorAdapter({
notes,
note,
onCreateNote,
onDraftChange,
onOpenNote,
}: {
notes: MobileNote[]
note: MobileNote
onCreateNote: () => void
onDraftChange?: (draft: MobileEditorDraft) => void
onOpenNote?: (noteId: string) => void
}) {
const document = useMemo(() => createMobileEditorDocument(note), [note])
const initialContent = useMemo(() => createMobileEditorHtml(document), [document])
const [wikilinkQuery, setWikilinkQuery] = useState<string | null>(null)
const [wikilinkFrame, setWikilinkFrame] = useState<MobileEditorWikilinkFrame | null>(null)
const draftTargetRef = useRef({ note, onDraftChange })
useEffect(() => {
draftTargetRef.current = { note, onDraftChange }
}, [note, onDraftChange])
const editor = useEditorBridge({
avoidIosKeyboard: true,
bridgeExtensions: mobileEditorBridgeExtensions,
initialContent,
onChange: () => {
const draftTarget = draftTargetRef.current
void editor.getHTML().then((editorHtml) => {
draftTarget.onDraftChange?.(createMobileEditorDraft({ editorHtml, note: draftTarget.note }))
})
},
})
const handleMessage = (event: WebViewMessageEvent) => {
const message = parseEditorMessage(event.nativeEvent.data)
if (!message) return
if (message.type === 'shortcut' && message.command === 'fileNewNote') {
onCreateNote()
return
}
if (message.type === 'wikilinkQuery') {
setWikilinkQuery(message.query)
setWikilinkFrame(message.frame)
return
}
if (message.type === 'listIndent') {
handleListIndent({ direction: message.direction, editor })
return
}
if (message.type !== 'openWikilink') return
const targetNote = resolveMobileRelationshipNote({ notes, target: message.target })
if (targetNote) {
onOpenNote?.(targetNote.id)
}
}
useEffect(() => {
const timer = setTimeout(() => {
applyMobileEditorWebViewSetup(editor)
}, 250)
return () => clearTimeout(timer)
}, [editor, note.id])
return (
<View style={styles.editorAdapterContent}>
<View style={styles.tentapEditor}>
<RichText key={note.id} editor={editor} onLoad={() => applyMobileEditorWebViewSetup(editor)} onMessage={handleMessage} />
</View>
<MobileEditorWikilinkSuggestions
excludeNoteId={note.id}
frame={wikilinkFrame}
notes={notes}
onSelectNote={(targetNote) => {
editor.insertWikilink({ label: targetNote.title, target: targetNote.id })
setWikilinkQuery(null)
setWikilinkFrame(null)
}}
query={wikilinkQuery}
/>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.tentapToolbar}
>
<Toolbar editor={editor} />
</KeyboardAvoidingView>
</View>
)
}
function applyMobileEditorWebViewSetup(editor: ReturnType<typeof useEditorBridge>) {
editor.injectCSS(mobileEditorCss, 'tolaria-mobile-editor')
editor.injectJS(mobileEditorSetupScript)
}
function handleListIndent({
direction,
editor,
}: {
direction: 'in' | 'out'
editor: ReturnType<typeof useEditorBridge>
}) {
if (direction === 'in') {
editor.sink()
return
}
editor.lift()
}

View File

@@ -1,81 +0,0 @@
import { Archive, Code, PencilSimpleLine, Star, Tray } from 'phosphor-react-native'
import type { ReactNode } from 'react'
import { Pressable, Text, View } from 'react-native'
import type { MobileNote } from './demoData'
import type { MobileEditorSaveState } from './mobileEditorSaveState'
import { styles } from './styles'
import { colors } from './theme'
export function MobileEditorBreadcrumb({
isRawMode,
note,
onToggleArchive,
onToggleFavorite,
onToggleRawMode,
saveState,
}: {
isRawMode: boolean
note: MobileNote
onToggleArchive: () => void
onToggleFavorite: () => void
onToggleRawMode: () => void
saveState: MobileEditorSaveState
}) {
const ArchiveIcon = note.archived ? Tray : Archive
const RawIcon = isRawMode ? PencilSimpleLine : Code
return (
<View style={styles.editorBreadcrumb}>
<Text numberOfLines={1} style={styles.editorBreadcrumbText}>{note.type}</Text>
<Text style={styles.editorBreadcrumbDivider}>/</Text>
<Text numberOfLines={1} style={styles.editorBreadcrumbTitle}>{note.id}</Text>
<Text style={[styles.editorSaveState, saveStateStyle(saveState)]}>{saveState.label}</Text>
<BreadcrumbButton label={isRawMode ? 'Rich editor' : 'Raw editor'} onPress={onToggleRawMode}>
<RawIcon color={isRawMode ? colors.primary : colors.textSoft} size={18} />
</BreadcrumbButton>
<BreadcrumbButton label={note.favorite ? 'Remove favorite' : 'Add favorite'} onPress={onToggleFavorite}>
<Star color={note.favorite ? colors.primary : colors.textSoft} size={18} weight={note.favorite ? 'fill' : 'regular'} />
</BreadcrumbButton>
<BreadcrumbButton label={note.archived ? 'Move to inbox' : 'Archive'} onPress={onToggleArchive}>
<ArchiveIcon color={colors.textSoft} size={18} />
</BreadcrumbButton>
</View>
)
}
function saveStateStyle(saveState: MobileEditorSaveState) {
switch (saveState.state) {
case 'blocked':
return styles.editorSaveState_blocked
case 'failed':
return styles.editorSaveState_failed
case 'queued':
return styles.editorSaveState_queued
case 'saved':
return styles.editorSaveState_saved
case 'saving':
return styles.editorSaveState_saving
default:
return styles.editorSaveState_idle
}
}
function BreadcrumbButton({
children,
label,
onPress,
}: {
children: ReactNode
label: string
onPress: () => void
}) {
return (
<Pressable
accessibilityLabel={label}
onPress={onPress}
style={({ pressed }) => [styles.editorBreadcrumbButton, pressed ? styles.pressed : null]}
>
{children}
</Pressable>
)
}

View File

@@ -1,57 +0,0 @@
import { useMemo } from 'react'
import { Pressable, Text, View } from 'react-native'
import type { MobileEditorWikilinkFrame } from './mobileEditorMessages'
import type { MobileNote } from './mobileNoteProjection'
import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { styles } from './styles'
export function MobileEditorWikilinkSuggestions({
frame,
excludeNoteId,
notes,
onSelectNote,
query,
}: {
frame: MobileEditorWikilinkFrame | null
excludeNoteId: string
notes: MobileNote[]
onSelectNote: (note: MobileNote) => void
query: string | null
}) {
const suggestions = useMemo(() => {
return query === null
? []
: mobileNoteSuggestions({ excludeNoteId, notes, query })
}, [excludeNoteId, notes, query])
if (query === null || suggestions.length === 0) {
return null
}
return (
<View style={[styles.rawEditorSuggestionMenu, suggestionMenuPosition(frame)]}>
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
onPress={() => onSelectNote(suggestion)}
style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]}
>
<Text style={styles.rawEditorSuggestionTitle}>{suggestion.title}</Text>
<Text style={styles.rawEditorSuggestionMeta}>{suggestion.id}</Text>
</Pressable>
))}
</View>
)
}
function suggestionMenuPosition(frame: MobileEditorWikilinkFrame | null) {
if (!frame) {
return null
}
return {
bottom: undefined,
left: Math.max(16, frame.left),
top: Math.max(16, frame.bottom + 8),
}
}

View File

@@ -1,53 +0,0 @@
import { GitBranch, WarningCircle } from 'phosphor-react-native'
import { Pressable, Text, View } from 'react-native'
import { mobileGitSyncStatusView, type MobileGitSyncStatusTone } from './mobileGitSyncStatus'
import type { MobileGitSyncPlan } from './mobileGitSyncPlan'
import { styles } from './styles'
import { colors } from './theme'
export function MobileGitSyncStatusCard({
onPrimaryAction,
plan,
}: {
onPrimaryAction?: () => void
plan: MobileGitSyncPlan
}) {
const status = mobileGitSyncStatusView(plan)
if (!status) {
return null
}
const Icon = status.tone === 'warning' || status.tone === 'attention' ? WarningCircle : GitBranch
return (
<View style={[styles.gitSyncStatus, gitSyncToneStyle(status.tone)]}>
<Icon size={18} color={colors.textSoft} />
<View style={styles.gitSyncStatusCopy}>
<Text style={styles.gitSyncStatusLabel}>{status.label}</Text>
<Text style={styles.gitSyncStatusDetail}>{status.detail}</Text>
</View>
{status.actionLabel ? (
<Pressable
accessibilityLabel={status.actionLabel}
onPress={onPrimaryAction}
style={({ pressed }) => pressed ? styles.pressed : null}
>
<Text style={styles.gitSyncStatusAction}>{status.actionLabel}</Text>
</Pressable>
) : null}
</View>
)
}
function gitSyncToneStyle(tone: MobileGitSyncStatusTone) {
switch (tone) {
case 'attention':
return styles.gitSyncStatus_attention
case 'neutral':
return styles.gitSyncStatus_neutral
case 'positive':
return styles.gitSyncStatus_positive
case 'warning':
return styles.gitSyncStatus_warning
}
}

View File

@@ -1,477 +0,0 @@
import { CaretLeft, Plus, X } from 'phosphor-react-native'
import { useState, type ReactNode } from 'react'
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'
import type { MobileNote } from './demoData'
import { mobileDerivedRelationshipGroups } from './mobileDerivedRelationships'
import { MobileEditablePropertyPickers } from './MobileEditablePropertyPickers'
import { MobileRelationshipNotePicker } from './MobileRelationshipNotePicker'
import type { MobileNotePropertyPatch } from './mobileNoteProperties'
import { nextMobilePropertyPicker, type MobilePropertyPickerKey } from './mobilePropertyPicker'
import {
canonicalMobileRelationshipRef,
filterMobileRelationshipRef,
hasMobileRelationshipRef,
mobileRelationshipDisplayLabel,
mobileWikilinkForNote,
resolveMobileRelationshipNote,
uniqueMobileRelationshipRefs,
} from './mobileRelationshipRefs'
import { mobileRelationshipAppearance } from './mobileTypeAppearance'
import { styles } from './styles'
import { colors } from './theme'
export function MobilePropertiesPanel({
failed = false,
isSaving = false,
notes = [],
note,
onChangeProperties,
onClose,
onOpenNote,
}: {
failed?: boolean
isSaving?: boolean
notes?: MobileNote[]
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onClose?: () => void
onOpenNote?: (noteId: string) => void
}) {
const [openPicker, setOpenPicker] = useState<MobilePropertyPickerKey | null>(null)
const selectPicker = (selected: MobilePropertyPickerKey) => {
setOpenPicker((current) => nextMobilePropertyPicker({ current, selected }))
}
return (
<View style={styles.properties}>
<PanelToolbar onClose={onClose} />
<ScrollView contentContainerStyle={styles.propertiesContent}>
{failed ? <Text style={styles.propertyError}>Could not save property.</Text> : null}
<PropertySection title="System">
<MobileEditablePropertyPickers
disabled={isSaving}
note={note}
openPicker={openPicker}
onChangeProperties={onChangeProperties}
onSelectPicker={selectPicker}
/>
</PropertySection>
<PropertySection title="Relationships">
<EditableRelationships note={note} notes={notes} onChangeProperties={onChangeProperties} onOpenNote={onOpenNote} />
<DerivedRelationships note={note} notes={notes} onOpenNote={onOpenNote} />
</PropertySection>
<CustomProperties note={note} onChangeProperties={onChangeProperties} />
<PropertySection title="Info">
<BacklinkGroup backlinks={note.backlinks} onOpenNote={onOpenNote} />
<PropertyRow label="Words" value={String(note.words)} />
<PropertyRow label="Modified" value={note.modified} />
</PropertySection>
<PropertySection title="History">
<Text style={styles.historyItem}>eb373865c - Updated 1 note</Text>
<Text style={styles.historyItem}>5e853fdfe - Updated 1 note</Text>
</PropertySection>
</ScrollView>
</View>
)
}
function PropertySection({
children,
title,
}: {
children: ReactNode
title: string
}) {
return (
<View style={styles.propertySection}>
<Text style={styles.propertyGroupTitle}>{title}</Text>
{children}
</View>
)
}
function EditableRelationships({
note,
notes,
onChangeProperties,
onOpenNote,
}: {
note: MobileNote
notes: MobileNote[]
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
onOpenNote?: (noteId: string) => void
}) {
return (
<>
<RelationshipGroup
label="Belongs to"
notes={notes}
targets={note.belongsTo}
onChangeTargets={(belongsTo) => onChangeProperties?.({ belongsTo })}
onOpenNote={onOpenNote}
/>
<RelationshipGroup
label="Related to"
notes={notes}
targets={[...note.relatedTo, ...note.outgoingLinks]}
writableTargets={note.relatedTo}
onChangeTargets={(relatedTo) => onChangeProperties?.({ relatedTo })}
onOpenNote={onOpenNote}
/>
<RelationshipGroup
label="Has"
notes={notes}
targets={note.has}
onChangeTargets={(has) => onChangeProperties?.({ has })}
onOpenNote={onOpenNote}
/>
{Object.entries(note.relationships).map(([key, targets]) => (
<RelationshipGroup
key={key}
label={formatRelationshipLabel(key)}
notes={notes}
onDeleteGroup={() => onChangeProperties?.({
relationships: removeRelationshipKey({ key, relationships: note.relationships }),
removedRelationshipKeys: [key],
})}
targets={targets}
onChangeTargets={(nextTargets) => onChangeProperties?.({
relationships: { ...note.relationships, [key]: nextTargets },
removedRelationshipKeys: nextTargets.length === 0 ? [key] : undefined,
})}
onOpenNote={onOpenNote}
/>
))}
<AddRelationshipGroup
note={note}
notes={notes}
onAdd={(key, target) => onChangeProperties?.({ relationships: { ...note.relationships, [key]: [target] } })}
/>
</>
)
}
function DerivedRelationships({
note,
notes,
onOpenNote,
}: {
note: MobileNote
notes: MobileNote[]
onOpenNote?: (noteId: string) => void
}) {
const groups = mobileDerivedRelationshipGroups({ note, notes })
return (
<>
{groups.map((group) => (
<RelationshipGroup
key={group.label}
label={group.label}
notes={notes}
targets={group.targets}
onOpenNote={onOpenNote}
/>
))}
</>
)
}
function RelationshipGroup({
label,
notes,
onChangeTargets,
onDeleteGroup,
onOpenNote,
targets,
writableTargets = targets,
}: {
label: string
notes: MobileNote[]
onChangeTargets?: (targets: string[]) => void
onDeleteGroup?: () => void
onOpenNote?: (noteId: string) => void
targets: string[]
writableTargets?: string[]
}) {
const [isAdding, setIsAdding] = useState(false)
const uniqueTargets = uniqueMobileRelationshipRefs(targets)
const addTarget = (selectedNote: MobileNote) => {
const target = canonicalMobileRelationshipRef({ notes, value: mobileWikilinkForNote(selectedNote) })
if (!target) return
onChangeTargets?.(uniqueMobileRelationshipRefs([...writableTargets, target]))
setIsAdding(false)
}
return (
<View style={styles.relationshipGroup}>
<RelationshipHeader
canAdd={Boolean(onChangeTargets)}
canDelete={Boolean(onDeleteGroup)}
label={label}
onAdd={() => setIsAdding(true)}
onDelete={onDeleteGroup}
/>
<View style={styles.relationshipChipRow}>
{uniqueTargets.length === 0 ? <Text style={styles.relationshipEmpty}>None</Text> : null}
{uniqueTargets.map((target) => (
<RelationshipChip
key={target}
note={resolveMobileRelationshipNote({ notes, target })}
onRemove={hasMobileRelationshipRef({ target, values: writableTargets }) && onChangeTargets
? () => onChangeTargets(filterMobileRelationshipRef({ target, values: writableTargets }))
: undefined}
target={target}
onOpenNote={onOpenNote}
/>
))}
</View>
<MobileRelationshipNotePicker
notes={notes}
title={`Add ${label}`}
visible={isAdding}
onClose={() => setIsAdding(false)}
onSelectNote={addTarget}
/>
</View>
)
}
function RelationshipHeader({
canAdd,
canDelete,
label,
onAdd,
onDelete,
}: {
canAdd: boolean
canDelete: boolean
label: string
onAdd: () => void
onDelete?: () => void
}) {
return (
<View style={styles.relationshipHeader}>
<Text style={styles.propertyGroupTitle}>{label}</Text>
<View style={styles.relationshipHeaderActions}>
{canDelete ? (
<Pressable onPress={onDelete} style={({ pressed }) => [styles.relationshipAddButton, pressed ? styles.pressed : null]}>
<X color={colors.textSoft} size={14} />
</Pressable>
) : null}
{canAdd ? (
<Pressable onPress={onAdd} style={({ pressed }) => [styles.relationshipAddButton, pressed ? styles.pressed : null]}>
<Plus color={colors.textSoft} size={14} />
</Pressable>
) : null}
</View>
</View>
)
}
function AddRelationshipGroup({
note,
notes,
onAdd,
}: {
note: MobileNote
notes: MobileNote[]
onAdd: (key: string, target: string) => void
}) {
const [name, setName] = useState('')
const [pendingKey, setPendingKey] = useState<string | null>(null)
const openTargetPicker = () => {
const key = relationshipKeyFromLabel(name)
if (key && !note.relationships[key]) {
setPendingKey(key)
}
}
return (
<View style={styles.relationshipGroup}>
<TextInput
autoCapitalize="none"
autoCorrect={false}
onChangeText={setName}
onSubmitEditing={openTargetPicker}
placeholder="+ Add relationship"
placeholderTextColor={colors.mutedText}
style={styles.relationshipInput}
value={name}
/>
<MobileRelationshipNotePicker
notes={notes}
title={`Add ${formatRelationshipLabel(pendingKey ?? 'relationship')}`}
visible={Boolean(pendingKey)}
onClose={() => setPendingKey(null)}
onSelectNote={(targetNote) => {
if (!pendingKey) return
onAdd(pendingKey, mobileWikilinkForNote(targetNote))
setName('')
setPendingKey(null)
}}
/>
</View>
)
}
function CustomProperties({
note,
onChangeProperties,
}: {
note: MobileNote
onChangeProperties?: (patch: MobileNotePropertyPatch) => void
}) {
const entries = Object.entries(note.customProperties)
return (
<PropertySection title="Custom properties">
{entries.length === 0 ? <Text style={styles.relationshipEmpty}>None</Text> : null}
{entries.map(([key, value]) => (
<PropertyRow
key={key}
label={formatRelationshipLabel(key)}
value={value}
onDelete={() => onChangeProperties?.({
customProperties: removeCustomPropertyKey({ customProperties: note.customProperties, key }),
removedCustomPropertyKeys: [key],
})}
/>
))}
</PropertySection>
)
}
function BacklinkGroup({
backlinks,
onOpenNote,
}: {
backlinks: MobileNote['backlinks']
onOpenNote?: (noteId: string) => void
}) {
if (backlinks.length === 0) {
return null
}
return (
<View style={styles.relationshipGroup}>
<Text style={styles.propertyGroupTitle}>Linked from</Text>
<View style={styles.relationshipChipRow}>
{backlinks.map((backlink) => (
<RelationshipChip
key={backlink.id}
note={backlink}
target={backlink.title}
onOpenNote={onOpenNote}
/>
))}
</View>
</View>
)
}
function RelationshipChip({
note,
onOpenNote,
onRemove,
target,
}: {
note?: { id: string; title: string; type?: string }
onOpenNote?: (noteId: string) => void
onRemove?: () => void
target: string
}) {
const appearance = mobileRelationshipAppearance(note?.type)
const chip = <Text style={styles.relationshipChipText}>{note?.title ?? mobileRelationshipDisplayLabel(target)}</Text>
const content = (
<>
{chip}
{onRemove ? (
<Pressable onPress={onRemove} style={styles.relationshipRemoveButton}>
<X color={appearance.color} size={12} />
</Pressable>
) : null}
</>
)
return note && onOpenNote ? (
<Pressable
onPress={() => onOpenNote(note.id)}
style={({ pressed }) => [
styles.relationshipChip,
{ backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor },
pressed ? styles.pressed : null,
]}
>
{content}
</Pressable>
) : (
<View style={[styles.relationshipChip, { backgroundColor: appearance.backgroundColor, borderColor: appearance.borderColor }]}>{content}</View>
)
}
function formatRelationshipLabel(key: string) {
return key.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
}
function relationshipKeyFromLabel(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
}
function PanelToolbar({ onClose }: { onClose?: () => void }) {
return (
<View style={styles.toolbar}>
<Text style={styles.propertiesTitle}>Properties</Text>
<View style={styles.toolbarSpacer} />
{onClose ? (
<Pressable onPress={onClose} style={({ pressed }) => [styles.iconButton, pressed ? styles.pressed : null]}>
<CaretLeft size={23} color={colors.textSoft} />
</Pressable>
) : null}
</View>
)
}
function PropertyRow({
label,
onDelete,
value,
}: {
label: string
onDelete?: () => void
value: string
}) {
return (
<View style={styles.propertyRow}>
<Text style={styles.propertyLabel}>{label}</Text>
<Text style={styles.propertyValue}>{value}</Text>
{onDelete ? (
<Pressable onPress={onDelete} style={styles.relationshipRemoveButton}>
<X color={colors.textSoft} size={14} />
</Pressable>
) : null}
</View>
)
}
function removeRelationshipKey({
key,
relationships,
}: {
key: string
relationships: Record<string, string[]>
}) {
return Object.fromEntries(Object.entries(relationships).filter(([relationshipKey]) => relationshipKey !== key))
}
function removeCustomPropertyKey({
customProperties,
key,
}: {
customProperties: Record<string, string>
key: string
}) {
return Object.fromEntries(Object.entries(customProperties).filter(([propertyKey]) => propertyKey !== key))
}

View File

@@ -1,62 +0,0 @@
import { useMemo, useState } from 'react'
import { Pressable, Text, TextInput, View } from 'react-native'
import type { MobileNote } from './mobileNoteProjection'
import { activeMobileWikilinkQuery, insertMobileWikilink, mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { styles } from './styles'
export function MobileRawEditor({
notes,
note,
onRawMarkdownChange,
}: {
notes: MobileNote[]
note: MobileNote
onRawMarkdownChange: (markdown: string) => void
}) {
const [draft, setDraft] = useState(note.content)
const [cursor, setCursor] = useState(note.content.length)
const activeQuery = useMemo(() => activeMobileWikilinkQuery({ cursor, markdown: draft }), [cursor, draft])
const suggestions = useMemo(
() => activeQuery ? mobileNoteSuggestions({ excludeNoteId: note.id, notes, query: activeQuery.query }) : [],
[activeQuery, note.id, notes],
)
return (
<View style={styles.rawEditorContent}>
<TextInput
autoCapitalize="none"
autoCorrect={false}
multiline
onChangeText={(markdown) => {
setDraft(markdown)
onRawMarkdownChange(markdown)
}}
onSelectionChange={(event) => setCursor(event.nativeEvent.selection.start)}
scrollEnabled
spellCheck={false}
style={styles.rawEditorInput}
textAlignVertical="top"
value={draft}
/>
{suggestions.length > 0 && activeQuery ? (
<View style={styles.rawEditorSuggestionMenu}>
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
onPress={() => {
const nextDraft = insertMobileWikilink({ markdown: draft, note: suggestion, query: activeQuery })
setDraft(nextDraft)
setCursor(activeQuery.start + suggestion.id.length + suggestion.title.length + 5)
onRawMarkdownChange(nextDraft)
}}
style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]}
>
<Text style={styles.rawEditorSuggestionTitle}>{suggestion.title}</Text>
<Text style={styles.rawEditorSuggestionMeta}>{suggestion.id}</Text>
</Pressable>
))}
</View>
) : null}
</View>
)
}

View File

@@ -1,73 +0,0 @@
import { X } from 'phosphor-react-native'
import { useMemo, useState } from 'react'
import { Modal, Pressable, Text, TextInput, View, type GestureResponderEvent } from 'react-native'
import type { MobileNote } from './mobileNoteProjection'
import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { styles } from './styles'
import { colors } from './theme'
export function MobileRelationshipNotePicker({
notes,
onClose,
onSelectNote,
title,
visible,
}: {
notes: MobileNote[]
onClose: () => void
onSelectNote: (note: MobileNote) => void
title: string
visible: boolean
}) {
const [query, setQuery] = useState('')
const suggestions = useMemo(
() => query.trim() ? mobileNoteSuggestions({ notes, query }) : [],
[notes, query],
)
return (
<Modal animationType="fade" transparent visible={visible} onRequestClose={onClose}>
<Pressable style={styles.relationshipPickerOverlay} onPress={onClose}>
<Pressable style={styles.relationshipPickerPanel} onPress={stopPressPropagation}>
<View style={styles.relationshipPickerHeader}>
<Text style={styles.relationshipPickerTitle}>{title}</Text>
<Pressable onPress={onClose} style={styles.relationshipPickerClose}>
<X color={colors.textSoft} size={18} />
</Pressable>
</View>
<TextInput
autoCapitalize="none"
autoCorrect={false}
autoFocus
onChangeText={setQuery}
placeholder="Search notes"
placeholderTextColor={colors.mutedText}
style={styles.relationshipPickerInput}
value={query}
/>
<View style={styles.relationshipPickerResults}>
{query.trim() ? null : <Text style={styles.relationshipPickerEmpty}>Type to find a note.</Text>}
{query.trim() && suggestions.length === 0 ? <Text style={styles.relationshipPickerEmpty}>No matching notes.</Text> : null}
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
onPress={() => {
onSelectNote(suggestion)
setQuery('')
}}
style={({ pressed }) => [styles.relationshipPickerResult, pressed ? styles.pressed : null]}
>
<Text numberOfLines={1} style={styles.relationshipPickerResultTitle}>{suggestion.title}</Text>
<Text numberOfLines={1} style={styles.relationshipPickerResultMeta}>{suggestion.type}</Text>
</Pressable>
))}
</View>
</Pressable>
</Pressable>
</Modal>
)
}
function stopPressPropagation(event: GestureResponderEvent) {
event.stopPropagation()
}

View File

@@ -1,45 +0,0 @@
import { Pressable, Text, View } from 'react-native'
import { GitBranch, HardDrive, SlidersHorizontal } from 'phosphor-react-native'
import { styles } from './styles'
import { colors } from './theme'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
import { createMobileVaultManagementSummary } from './mobileVaultManagementSummary'
export function MobileVaultManagementCard({
onOpenRemoteSetup,
vault,
}: {
onOpenRemoteSetup: () => void
vault: MobileVaultMetadata
}) {
const summary = createMobileVaultManagementSummary(vault)
return (
<View style={styles.vaultManagementCard}>
<View style={styles.vaultManagementHeader}>
<View style={styles.vaultManagementIcon}>
<HardDrive size={18} color={colors.primary} />
</View>
<View style={styles.vaultManagementTitleGroup}>
<Text numberOfLines={1} style={styles.vaultManagementTitle}>{summary.name}</Text>
<Text style={styles.vaultManagementDetail}>{summary.storageLabel}</Text>
</View>
</View>
<View style={styles.vaultManagementRemoteRow}>
<GitBranch size={17} color={colors.iconMuted} />
<View style={styles.vaultManagementRemoteText}>
<Text style={styles.vaultManagementRemoteLabel}>{summary.remoteLabel}</Text>
<Text numberOfLines={1} style={styles.vaultManagementDetail}>{summary.remoteDetail}</Text>
</View>
</View>
<Pressable
accessibilityLabel="Configure Git remote"
onPress={onOpenRemoteSetup}
style={({ pressed }) => [styles.vaultManagementAction, pressed ? styles.pressed : null]}
>
<SlidersHorizontal size={17} color={colors.primary} />
<Text style={styles.vaultManagementActionText}>{summary.actionLabel}</Text>
</Pressable>
</View>
)
}

View File

@@ -1,73 +0,0 @@
import { Pressable, Text, TextInput, View } from 'react-native'
import { styles } from './styles'
export function MobileVaultRemotePrompt({
failed,
hasGitHubOAuthClientId,
isSaving,
onCancel,
onChangeRemoteUrl,
onSubmit,
remoteUrl,
}: {
failed: boolean
hasGitHubOAuthClientId: boolean
isSaving: boolean
onCancel: () => void
onChangeRemoteUrl: (remoteUrl: string) => void
onSubmit: () => void
remoteUrl: string
}) {
return (
<View style={styles.remotePrompt}>
<Text style={styles.remotePromptTitle}>Git remote</Text>
<TextInput
accessibilityLabel="Git remote URL"
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
onChangeText={onChangeRemoteUrl}
onSubmitEditing={onSubmit}
placeholder="https://github.com/owner/repo.git"
returnKeyType="done"
style={styles.remotePromptInput}
value={remoteUrl}
/>
{!hasGitHubOAuthClientId ? (
<Text style={styles.remotePromptError}>GitHub login needs EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID</Text>
) : null}
{failed ? <Text style={styles.remotePromptError}>Enter a valid Git remote URL</Text> : null}
<View style={styles.remotePromptActions}>
<PromptButton label="Cancel" onPress={onCancel} />
<PromptButton label={isSaving ? 'Saving' : 'Save'} onPress={onSubmit} disabled={isSaving} primary />
</View>
</View>
)
}
function PromptButton({
disabled,
label,
onPress,
primary,
}: {
disabled?: boolean
label: string
onPress: () => void
primary?: boolean
}) {
return (
<Pressable
disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.remotePromptAction,
primary ? styles.remotePromptActionPrimary : null,
disabled ? styles.remotePromptActionDisabled : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.remotePromptActionText, primary ? styles.remotePromptActionTextPrimary : null]}>{label}</Text>
</Pressable>
)
}

View File

@@ -1,36 +0,0 @@
import {
Archive,
Books,
Drop,
FileText,
Flag,
GitBranch,
PenNib,
Robot,
Star,
Sun,
Tray,
Wrench,
} from 'phosphor-react-native'
export type IconName = 'archive' | 'books' | 'drop' | 'file-text' | 'flag' | 'git-branch' | 'pen-nib' | 'robot' | 'star' | 'sun' | 'tray' | 'wrench'
const iconByName = {
archive: Archive,
books: Books,
drop: Drop,
'file-text': FileText,
flag: Flag,
'git-branch': GitBranch,
'pen-nib': PenNib,
robot: Robot,
star: Star,
sun: Sun,
tray: Tray,
wrench: Wrench,
} as const
export function NamedIcon({ color, name, size }: { color: string; name: IconName; size: number }) {
const Icon = iconByName[name]
return <Icon color={color} size={size} weight="regular" />
}

View File

@@ -1,37 +0,0 @@
import { View } from 'react-native'
import {
PanGestureHandler,
State,
type PanGestureHandlerStateChangeEvent,
} from 'react-native-gesture-handler'
import { compactSwipeEvent, detectHorizontalSwipe } from './compactGestures'
import type { CompactNavigationEvent, CompactPanel } from './compactNavigation'
import { styles } from './styles'
export function SwipeSurface({
children,
panel,
onNavigate,
}: {
children: React.ReactNode
panel: CompactPanel
onNavigate: (event: CompactNavigationEvent) => void
}) {
const handleStateChange = (event: PanGestureHandlerStateChangeEvent) => {
if (event.nativeEvent.state !== State.END) {
return
}
const direction = detectHorizontalSwipe(event.nativeEvent)
const navigationEvent = direction ? compactSwipeEvent(panel, direction) : null
if (navigationEvent) {
onNavigate(navigationEvent)
}
}
return (
<PanGestureHandler activeOffsetX={[-18, 18]} failOffsetY={[-24, 24]} onHandlerStateChange={handleStateChange}>
<View style={styles.swipeSurface}>{children}</View>
</PanGestureHandler>
)
}

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest'
import { compactSwipeEvent, detectHorizontalSwipe } from './compactGestures'
describe('compact mobile gestures', () => {
it('ignores short slow horizontal drags', () => {
expect(detectHorizontalSwipe({ translationX: 24, velocityX: 90 })).toBeNull()
})
it('detects committed left and right swipes', () => {
expect(detectHorizontalSwipe({ translationX: -72, velocityX: -120 })).toBe('left')
expect(detectHorizontalSwipe({ translationX: 20, velocityX: 520 })).toBe('right')
})
it('maps the requested Bear-style compact panel swipes', () => {
expect(compactSwipeEvent('list', 'left')).toEqual({ type: 'openSidebar' })
expect(compactSwipeEvent('sidebar', 'right')).toEqual({ type: 'closeSidebar' })
expect(compactSwipeEvent('note', 'right')).toEqual({ type: 'openProperties' })
expect(compactSwipeEvent('properties', 'left')).toEqual({ type: 'closeProperties' })
})
it('does not invent transitions for unsupported panel directions', () => {
expect(compactSwipeEvent('list', 'right')).toBeNull()
expect(compactSwipeEvent('note', 'left')).toBeNull()
})
})

View File

@@ -1,36 +0,0 @@
import type { CompactNavigationEvent, CompactPanel } from './compactNavigation'
export type SwipeDirection = 'left' | 'right'
export type SwipeSample = {
translationX: number
velocityX: number
}
const MIN_TRANSLATION = 56
const MIN_VELOCITY = 420
const compactGestureEvents: Partial<Record<`${CompactPanel}:${SwipeDirection}`, CompactNavigationEvent>> = {
'list:left': { type: 'openSidebar' },
'sidebar:right': { type: 'closeSidebar' },
'note:right': { type: 'openProperties' },
'properties:left': { type: 'closeProperties' },
}
export function detectHorizontalSwipe(sample: SwipeSample): SwipeDirection | null {
if (!isCommittedSwipe(sample)) {
return null
}
return sample.translationX < 0 ? 'left' : 'right'
}
export function compactSwipeEvent(
panel: CompactPanel,
direction: SwipeDirection,
): CompactNavigationEvent | null {
return compactGestureEvents[`${panel}:${direction}`] ?? null
}
function isCommittedSwipe(sample: SwipeSample) {
return Math.abs(sample.translationX) >= MIN_TRANSLATION || Math.abs(sample.velocityX) >= MIN_VELOCITY
}

View File

@@ -1,60 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
createCompactNavigationState,
transitionCompactNavigation,
type CompactNavigationState,
} from './compactNavigation'
describe('compact mobile navigation', () => {
it('starts on the note list with the first note selected', () => {
expect(createCompactNavigationState('workflow')).toEqual({
panel: 'list',
selectedNoteId: 'workflow',
})
})
it('opens a selected note from the list', () => {
const next = transitionCompactNavigation(createCompactNavigationState('workflow'), {
type: 'selectNote',
noteId: 'release',
})
expect(next).toEqual({
panel: 'note',
selectedNoteId: 'release',
})
})
it('returns from sidebar and note surfaces to the list', () => {
expect(panelAfter({ type: 'openSidebar' })).toBe('sidebar')
expect(panelAfter({ type: 'closeSidebar' }, 'sidebar')).toBe('list')
expect(panelAfter({ type: 'backToList' }, 'note')).toBe('list')
})
it('opens and closes note properties without changing the selected note', () => {
const noteState: CompactNavigationState = {
panel: 'note',
selectedNoteId: 'workflow',
}
const propertiesState = transitionCompactNavigation(noteState, { type: 'openProperties' })
const closedState = transitionCompactNavigation(propertiesState, { type: 'closeProperties' })
expect(propertiesState).toEqual({
panel: 'properties',
selectedNoteId: 'workflow',
})
expect(closedState).toEqual({
panel: 'note',
selectedNoteId: 'workflow',
})
})
})
function panelAfter(
event: Parameters<typeof transitionCompactNavigation>[1],
panel: CompactNavigationState['panel'] = 'list',
) {
const state = transitionCompactNavigation({ panel, selectedNoteId: 'workflow' }, event)
return state.panel
}

View File

@@ -1,55 +0,0 @@
export type CompactPanel = 'sidebar' | 'list' | 'note' | 'properties'
export type CompactNavigationState = {
panel: CompactPanel
selectedNoteId: string
}
export type CompactNavigationEvent =
| { type: 'backToList' }
| { type: 'closeProperties' }
| { type: 'closeSidebar' }
| { type: 'openProperties' }
| { type: 'openSidebar' }
| { type: 'selectNote'; noteId: string }
export function createCompactNavigationState(initialNoteId: string): CompactNavigationState {
return {
panel: 'list',
selectedNoteId: initialNoteId,
}
}
export function transitionCompactNavigation(
state: CompactNavigationState,
event: CompactNavigationEvent,
): CompactNavigationState {
if (event.type === 'selectNote') {
return { panel: 'note', selectedNoteId: event.noteId }
}
return {
...state,
panel: nextPanel(state.panel, event),
}
}
function nextPanel(currentPanel: CompactPanel, event: Exclude<CompactNavigationEvent, { type: 'selectNote' }>) {
if (event.type === 'openSidebar') {
return 'sidebar'
}
if (event.type === 'closeSidebar' || event.type === 'backToList') {
return 'list'
}
if (event.type === 'openProperties') {
return 'properties'
}
if (event.type === 'closeProperties') {
return 'note'
}
return currentPanel
}

View File

@@ -1,20 +0,0 @@
import { describe, expect, it } from 'vitest'
import { notes } from './demoData'
import { createMobileSidebarSections } from './mobileSidebarNavigation'
describe('mobile demo data', () => {
it('derives note titles and snippets through shared markdown utilities', () => {
expect(notes[0].title).toBe('Workflow Orchestration Essay')
expect(notes[0].snippet).toContain('The current narrative / temptation')
expect(notes[0].words).toBeGreaterThan(20)
})
it('keeps the initial sidebar focused on inbox', () => {
const sidebarSections = createMobileSidebarSections(notes)
expect(sidebarSections[0].items[0]).toMatchObject({
label: 'Inbox',
selection: { kind: 'library', id: 'inbox' },
})
})
})

View File

@@ -1,242 +0,0 @@
import { projectMobileNotes, type MobileNote, type MobileNoteSource } from './mobileNoteProjection'
import { createFixtureMobileVaultRepository } from './mobileVaultRepository'
export type { MobileNote } from './mobileNoteProjection'
export const demoNoteSources: MobileNoteSource[] = [
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: { review_stage: 'Draft outline' },
favorite: true,
favoriteIndex: 0,
has: ['workflow-orchestration-checklist'],
id: 'workflow',
type: 'Essay',
icon: 'pen-nib',
date: 'May 13, 2026',
modified: '6h ago',
filename: 'workflow.md',
relatedTo: ['release', 'mobile-roadmap'],
relationships: { people: ['Malte Ubl'], topics: ['workflow orchestration'] },
tags: ['Design Inspiration', 'Tolaria MVP'],
content: [
'---',
'title: Workflow Orchestration Essay',
'_favorite: true',
'_favorite_index: 0',
'type: Essay',
'status: Draft',
'tags: [Design Inspiration, Tolaria MVP]',
'belongs_to: [Tolaria MVP]',
'related_to: [release, mobile-roadmap]',
'has: [workflow-orchestration-checklist]',
'people: [Malte Ubl]',
'review_stage: Draft outline',
'topics: [workflow orchestration]',
'---',
'',
'# Workflow Orchestration Essay',
'',
'- The current narrative / temptation: everything routed through an LLM.',
'- A real example (Tolaria + OpenClaw): OpenClaw does a lot for me in product development.',
'- The cost of AI everywhere: expensive, slow, and unpredictable.',
'- Where orchestration wins: observability, human-in-the-loop approvals, reliability.',
'',
'This connects to [[mobile-roadmap|the mobile roadmap]] and the [[workflow-orchestration-checklist]].',
].join('\n'),
},
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: { platform: 'iPad first' },
favorite: true,
favoriteIndex: 1,
has: ['workflow'],
id: 'mobile-roadmap',
type: 'Project',
icon: 'wrench',
date: 'May 5, 2026',
modified: '1h ago',
filename: 'mobile-roadmap.md',
relatedTo: ['release'],
relationships: { depends_on: ['workflow-orchestration-checklist'] },
tags: ['Tolaria MVP', 'mobile'],
content: [
'---',
'title: Mobile Roadmap',
'_favorite: true',
'_favorite_index: 1',
'type: Project',
'status: Active',
'tags: [Tolaria MVP, mobile]',
'belongs_to: [Tolaria MVP]',
'related_to: [release]',
'has: [workflow]',
'depends_on: [workflow-orchestration-checklist]',
'platform: iPad first',
'---',
'',
'# Mobile Roadmap',
'',
'Local-first workflow parity comes before cloud sync.',
'',
'- Sidebar navigation by type',
'- Raw editor for direct markdown edits',
'- Wikilinks like [[workflow]] and [[release]]',
].join('\n'),
},
{
archived: false,
belongsTo: ['workflow'],
customProperties: {},
has: [],
id: 'workflow-orchestration-checklist',
type: 'Note',
icon: 'file-text',
date: 'May 5, 2026',
modified: '2h ago',
filename: 'workflow-orchestration-checklist.md',
relatedTo: ['workflow'],
relationships: {},
tags: ['mobile'],
content: [
'---',
'title: Workflow Orchestration Checklist',
'type: Note',
'status: Draft',
'tags: [mobile]',
'belongs_to: [workflow]',
'related_to: [workflow]',
'---',
'',
'# Workflow Orchestration Checklist',
'',
'- Validate breadcrumbs',
'- Validate [[workflow]] wikilinks',
'- Validate relationships panel',
].join('\n'),
},
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: {},
has: [],
id: 'release',
type: 'Release Note',
icon: 'flag',
date: 'May 2, 2026',
modified: '12h ago',
filename: 'release.md',
relatedTo: ['workflow', 'mobile-roadmap'],
relationships: {},
tags: ['Release', 'Stable'],
content: [
'---',
'title: v2026-05-02',
'type: Release Note',
'status: Done',
'tags: [Release, Stable]',
'belongs_to: [Tolaria MVP]',
'related_to: [workflow, mobile-roadmap]',
'---',
'',
'# v2026-05-02',
'',
'Another Tolaria release in the bag. This one is focused on performance, bug fixes, and lower-friction note workflows.',
'',
'Follow-up work lives in [[mobile-roadmap]].',
].join('\n'),
},
{
archived: false,
belongsTo: ['Tolaria MVP'],
customProperties: {},
has: ['resources'],
id: 'migration',
type: 'Project',
icon: 'git-branch',
date: 'Apr 28, 2026',
modified: '1d ago',
filename: 'migration.md',
relatedTo: ['mobile-roadmap'],
relationships: {},
tags: ['Project', 'Resources'],
content: [
'---',
'title: Tolaria <> Obsidian migration proposal',
'type: Project',
'status: Active',
'tags: [Project, Resources]',
'belongs_to: [Tolaria MVP]',
'related_to: [mobile-roadmap]',
'has: [resources]',
'---',
'',
'# Tolaria <> Obsidian migration proposal',
'',
'Obsidian vaults are already close to Tolaria ideal substrate: local Markdown, portable attachments, and git-backed history.',
].join('\n'),
},
{
archived: false,
belongsTo: ['migration'],
customProperties: {},
has: [],
id: 'resources',
type: 'Resource',
icon: 'books',
date: 'Apr 20, 2026',
modified: '3d ago',
filename: 'resources.md',
relatedTo: ['migration', 'mobile-roadmap'],
relationships: {},
tags: ['Resources', 'mobile'],
content: [
'---',
'title: Mobile Resources',
'type: Resource',
'status: Active',
'tags: [Resources, mobile]',
'belongs_to: [migration]',
'related_to: [migration, mobile-roadmap]',
'---',
'',
'# Mobile Resources',
'',
'References for [[mobile-roadmap]] and local vault workflow QA.',
].join('\n'),
},
{
archived: true,
belongsTo: [],
customProperties: {},
has: [],
id: 'old-mobile-spike',
type: 'Note',
icon: 'archive',
date: 'Mar 30, 2026',
modified: 'last month',
filename: 'old-mobile-spike.md',
relatedTo: ['mobile-roadmap'],
relationships: {},
tags: ['mobile'],
content: [
'---',
'title: Old Mobile Spike',
'type: Note',
'archived: true',
'tags: [mobile]',
'related_to: [mobile-roadmap]',
'---',
'',
'# Old Mobile Spike',
'',
'Archived prototype notes kept around for comparison with [[mobile-roadmap]].',
].join('\n'),
},
]
export const notes: MobileNote[] = projectMobileNotes(demoNoteSources)
export const demoVaultRepository = createFixtureMobileVaultRepository(demoNoteSources)

View File

@@ -1,58 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { sendMobileAiRequest } from './mobileAiClient'
import type { MobileNote } from './mobileNoteProjection'
describe('mobile AI client', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('sends an OpenAI-compatible chat completion request with note context', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
json: async () => ({ choices: [{ message: { content: 'Answer' } }] }),
ok: true,
} as Response)
await expect(sendMobileAiRequest({
apiKey: 'key',
note: note(),
prompt: 'Summarize',
provider: {
baseUrl: 'https://api.example.com/v1/',
id: 'provider',
kind: 'open_ai_compatible',
modelId: 'model',
name: 'Provider',
},
})).resolves.toBe('Answer')
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/v1/chat/completions', expect.objectContaining({
method: 'POST',
}))
})
})
function note(): MobileNote {
return {
archived: false,
backlinks: [],
belongsTo: [],
content: '# Workflow\n\nBody',
customProperties: {},
date: '',
favorite: false,
favoriteIndex: null,
has: [],
icon: 'file-text',
id: 'workflow',
modified: '',
outgoingLinks: [],
relatedTo: [],
relationships: {},
snippet: '',
tags: [],
title: 'Workflow',
type: 'Note',
words: 1,
}
}

View File

@@ -1,45 +0,0 @@
import type { MobileNote } from './mobileNoteProjection'
import type { MobileAiProvider } from './mobileAiSettings'
export type MobileAiRequest = {
apiKey: string
note: MobileNote
prompt: string
provider: MobileAiProvider
}
export async function sendMobileAiRequest(request: MobileAiRequest) {
const response = await fetch(`${request.provider.baseUrl.replace(/\/$/, '')}/chat/completions`, {
body: JSON.stringify({
messages: [
{
content: [
'You are helping with a Tolaria markdown note.',
'Use concise answers and preserve [[wikilink]] syntax when referencing notes.',
`Active note: ${request.note.title}`,
request.note.content,
].join('\n\n'),
role: 'system',
},
{ content: request.prompt, role: 'user' },
],
model: request.provider.modelId,
}),
headers: {
Authorization: `Bearer ${request.apiKey}`,
'Content-Type': 'application/json',
},
method: 'POST',
})
if (!response.ok) {
throw new Error(`AI request failed with ${response.status}`)
}
return extractAssistantMessage(await response.json())
}
function extractAssistantMessage(payload: unknown) {
const content = (payload as { choices?: Array<{ message?: { content?: unknown } }> }).choices?.[0]?.message?.content
return typeof content === 'string' ? content : ''
}

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createMobileAiProviderSecretStorage } from './mobileAiProviderSecretStorage'
describe('mobile AI provider secret storage', () => {
it('stores API keys in secure provider-scoped records', async () => {
const secrets = new Map<string, string>()
const storage = createMobileAiProviderSecretStorage({
deleteItemAsync: async (key) => {
secrets.delete(key)
},
getItemAsync: async (key) => secrets.get(key) ?? null,
setItemAsync: async (key, value) => {
secrets.set(key, value)
},
})
await storage.saveApiKey('Open-AI', 'secret')
expect(await storage.loadApiKey('open-ai')).toBe('secret')
await storage.removeApiKey('open-ai')
expect(await storage.loadApiKey('open-ai')).toBeNull()
})
})

View File

@@ -1,29 +0,0 @@
export type MobileAiProviderSecretStore = {
deleteItemAsync: (key: string) => Promise<void>
getItemAsync: (key: string) => Promise<string | null>
setItemAsync: (key: string, value: string) => Promise<void>
}
export type MobileAiProviderSecretStorage = {
loadApiKey: (providerId: string) => Promise<string | null>
removeApiKey: (providerId: string) => Promise<void>
saveApiKey: (providerId: string, apiKey: string) => Promise<void>
}
export function createMobileAiProviderSecretStorage(
secureStore: MobileAiProviderSecretStore,
): MobileAiProviderSecretStorage {
return {
loadApiKey: async (providerId) => secureStore.getItemAsync(providerKey(providerId)),
removeApiKey: async (providerId) => {
await secureStore.deleteItemAsync(providerKey(providerId))
},
saveApiKey: async (providerId, apiKey) => {
await secureStore.setItemAsync(providerKey(providerId), apiKey)
},
}
}
function providerKey(providerId: string) {
return ['tolaria', 'ai-provider', providerId.trim().toLowerCase(), 'api-key'].join(':')
}

View File

@@ -1,66 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildMobileAiProvider,
normalizeMobileAiSettings,
removeMobileAiProvider,
selectedMobileAiProvider,
upsertMobileAiProvider,
} from './mobileAiSettings'
describe('mobile AI settings', () => {
it('builds API providers from presets without storing API keys', () => {
expect(buildMobileAiProvider({
draft: {
apiKey: 'secret',
kind: 'open_ai',
modelId: ' gpt-4.1-mini ',
name: '',
},
providerId: 'open-ai',
})).toEqual({
baseUrl: 'https://api.openai.com/v1',
id: 'open-ai',
kind: 'open_ai',
modelId: 'gpt-4.1-mini',
name: 'OpenAI',
})
})
it('normalizes persisted settings and selected provider', () => {
const settings = normalizeMobileAiSettings({
defaultProviderId: 'open-ai',
providers: [{
baseUrl: 'https://api.openai.com/v1',
id: 'open-ai',
kind: 'open_ai',
modelId: 'gpt-4.1-mini',
name: 'OpenAI',
}],
})
expect(selectedMobileAiProvider(settings)?.id).toBe('open-ai')
})
it('upserts and removes providers while maintaining the default target', () => {
const settings = upsertMobileAiProvider({
provider: provider('one'),
settings: { defaultProviderId: null, providers: [] },
})
expect(settings.defaultProviderId).toBe('one')
expect(removeMobileAiProvider({ providerId: 'one', settings })).toEqual({
defaultProviderId: null,
providers: [],
})
})
})
function provider(id: string) {
return {
baseUrl: 'https://api.openai.com/v1',
id,
kind: 'open_ai' as const,
modelId: 'gpt-4.1-mini',
name: 'OpenAI',
}
}

View File

@@ -1,166 +0,0 @@
export type MobileAiProviderKind = 'anthropic' | 'gemini' | 'open_ai' | 'open_ai_compatible' | 'open_router'
export type MobileAiProvider = {
baseUrl: string
id: string
kind: MobileAiProviderKind
modelId: string
name: string
}
export type MobileAiSettings = {
defaultProviderId: string | null
providers: MobileAiProvider[]
}
export type MobileAiProviderDraft = {
apiKey: string
kind: MobileAiProviderKind
modelId: string
name: string
}
export const defaultMobileAiSettings: MobileAiSettings = {
defaultProviderId: null,
providers: [],
}
export const mobileAiProviderPresets: Record<MobileAiProviderKind, { baseUrl: string; name: string; placeholder: string }> = {
anthropic: {
baseUrl: 'https://api.anthropic.com/v1',
name: 'Anthropic',
placeholder: 'claude-3-5-sonnet-latest',
},
gemini: {
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
name: 'Gemini',
placeholder: 'gemini-2.5-flash',
},
open_ai: {
baseUrl: 'https://api.openai.com/v1',
name: 'OpenAI',
placeholder: 'gpt-4.1-mini',
},
open_ai_compatible: {
baseUrl: 'https://api.example.com/v1',
name: 'Custom provider',
placeholder: 'model-id',
},
open_router: {
baseUrl: 'https://openrouter.ai/api/v1',
name: 'OpenRouter',
placeholder: 'openai/gpt-4.1-mini',
},
}
export function buildMobileAiProvider({
draft,
providerId,
}: {
draft: MobileAiProviderDraft
providerId: string
}): MobileAiProvider {
const preset = mobileAiProviderPresets[draft.kind]
return {
baseUrl: preset.baseUrl,
id: providerId,
kind: draft.kind,
modelId: draft.modelId.trim(),
name: draft.name.trim() || preset.name,
}
}
export function normalizeMobileAiSettings(value: unknown): MobileAiSettings {
if (!isSettingsRecord(value)) {
return defaultMobileAiSettings
}
const providers = value.providers.flatMap(normalizeMobileAiProvider)
return {
defaultProviderId: defaultProviderId({ providers, value: value.defaultProviderId }),
providers,
}
}
export function selectedMobileAiProvider(settings: MobileAiSettings) {
return settings.providers.find((provider) => provider.id === settings.defaultProviderId) ?? settings.providers[0] ?? null
}
export function upsertMobileAiProvider({
provider,
settings,
}: {
provider: MobileAiProvider
settings: MobileAiSettings
}): MobileAiSettings {
const providers = [provider, ...settings.providers.filter((item) => item.id !== provider.id)]
return { defaultProviderId: provider.id, providers }
}
export function removeMobileAiProvider({
providerId,
settings,
}: {
providerId: string
settings: MobileAiSettings
}): MobileAiSettings {
const providers = settings.providers.filter((provider) => provider.id !== providerId)
return {
defaultProviderId: settings.defaultProviderId === providerId ? providers[0]?.id ?? null : settings.defaultProviderId,
providers,
}
}
function defaultProviderId({
providers,
value,
}: {
providers: MobileAiProvider[]
value: unknown
}) {
return typeof value === 'string' && providers.some((provider) => provider.id === value)
? value
: providers[0]?.id ?? null
}
function normalizeMobileAiProvider(value: unknown): MobileAiProvider[] {
if (!isProviderRecord(value)) {
return []
}
return [{
baseUrl: value.baseUrl.trim(),
id: value.id.trim(),
kind: value.kind,
modelId: value.modelId.trim(),
name: value.name.trim(),
}]
}
function isSettingsRecord(value: unknown): value is { defaultProviderId?: unknown; providers: unknown[] } {
return typeof value === 'object'
&& value !== null
&& Array.isArray((value as { providers?: unknown }).providers)
}
function isProviderRecord(value: unknown): value is MobileAiProvider {
return typeof value === 'object'
&& value !== null
&& hasText((value as MobileAiProvider).baseUrl)
&& hasText((value as MobileAiProvider).id)
&& isProviderKind((value as MobileAiProvider).kind)
&& hasText((value as MobileAiProvider).modelId)
&& hasText((value as MobileAiProvider).name)
}
function hasText(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function isProviderKind(value: unknown): value is MobileAiProviderKind {
return value === 'anthropic'
|| value === 'gemini'
|| value === 'open_ai'
|| value === 'open_ai_compatible'
|| value === 'open_router'
}

View File

@@ -1,34 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createMobileAiSettingsStorage } from './mobileAiSettingsStorage'
describe('mobile AI settings storage', () => {
it('loads default settings when no file exists and saves normalized settings', async () => {
const files = new Map<string, string>()
const storage = createMobileAiSettingsStorage({
documentDirectory: 'file:///docs',
getInfoAsync: async (uri) => ({ exists: files.has(uri) }),
makeDirectoryAsync: async (uri) => {
files.set(uri, '')
},
readAsStringAsync: async (uri) => files.get(uri) ?? '',
writeAsStringAsync: async (uri, content) => {
files.set(uri, content)
},
})
expect(await storage.load()).toEqual({ defaultProviderId: null, providers: [] })
await storage.save({
defaultProviderId: 'provider',
providers: [{
baseUrl: 'https://api.openai.com/v1',
id: 'provider',
kind: 'open_ai',
modelId: 'gpt-4.1-mini',
name: 'OpenAI',
}],
})
expect(await storage.load()).toMatchObject({ defaultProviderId: 'provider' })
})
})

View File

@@ -1,82 +0,0 @@
import { defaultMobileAiSettings, normalizeMobileAiSettings, type MobileAiSettings } from './mobileAiSettings'
export type MobileAiSettingsFileInfo = {
exists: boolean
isDirectory?: boolean
}
export type MobileAiSettingsFileSystem = {
documentDirectory: string | null
getInfoAsync: (uri: string) => Promise<MobileAiSettingsFileInfo>
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
readAsStringAsync: (uri: string) => Promise<string>
writeAsStringAsync: (uri: string, content: string) => Promise<void>
}
export type MobileAiSettingsStorage = {
load: () => Promise<MobileAiSettings>
save: (settings: MobileAiSettings) => Promise<void>
}
export function createMobileAiSettingsStorage(
fileSystem: MobileAiSettingsFileSystem,
): MobileAiSettingsStorage {
return {
load: async () => loadMobileAiSettings(fileSystem),
save: async (settings) => saveMobileAiSettings({ fileSystem, settings }),
}
}
async function loadMobileAiSettings(fileSystem: MobileAiSettingsFileSystem) {
const fileUri = settingsFileUri(fileSystem)
const info = await fileSystem.getInfoAsync(fileUri)
if (!info.exists || info.isDirectory) {
return defaultMobileAiSettings
}
return parseMobileAiSettings(await fileSystem.readAsStringAsync(fileUri))
}
async function saveMobileAiSettings({
fileSystem,
settings,
}: {
fileSystem: MobileAiSettingsFileSystem
settings: MobileAiSettings
}) {
await ensureDirectory({ fileSystem, uri: settingsRootUri(fileSystem) })
await fileSystem.writeAsStringAsync(settingsFileUri(fileSystem), JSON.stringify(settings))
}
function parseMobileAiSettings(content: string) {
try {
return normalizeMobileAiSettings(JSON.parse(content))
} catch {
return defaultMobileAiSettings
}
}
async function ensureDirectory({
fileSystem,
uri,
}: {
fileSystem: MobileAiSettingsFileSystem
uri: string
}) {
const info = await fileSystem.getInfoAsync(uri)
if (!info.exists) {
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
}
}
function settingsFileUri(fileSystem: MobileAiSettingsFileSystem) {
return `${settingsRootUri(fileSystem)}/ai-settings.json`
}
function settingsRootUri(fileSystem: MobileAiSettingsFileSystem) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
return `${fileSystem.documentDirectory.replace(/\/+$/, '')}/state`
}

View File

@@ -1,60 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
createMobileAppStateStorage,
type MobileAppStateFileSystem,
} from './mobileAppStateStorage'
describe('mobile app state storage', () => {
it('returns default state when no app state file exists', async () => {
const storage = createMobileAppStateStorage(createMemoryAppStateFileSystem())
await expect(storage.load('personal')).resolves.toEqual({
activeVaultId: 'personal',
selectedNoteId: null,
})
})
it('persists and restores selected note id for the active vault', async () => {
const fileSystem = createMemoryAppStateFileSystem()
const storage = createMobileAppStateStorage(fileSystem)
await storage.save({ activeVaultId: 'personal', selectedNoteId: 'workflow' })
await expect(storage.load('personal')).resolves.toEqual({
activeVaultId: 'personal',
selectedNoteId: 'workflow',
})
})
it('ignores corrupt or mismatched state files', async () => {
const fileSystem = createMemoryAppStateFileSystem({
'file:///docs/state/app-state.json': '{"activeVaultId":"other","selectedNoteId":"workflow"}',
})
const storage = createMobileAppStateStorage(fileSystem)
await expect(storage.load('personal')).resolves.toEqual({
activeVaultId: 'personal',
selectedNoteId: null,
})
})
})
function createMemoryAppStateFileSystem(files: Record<string, string> = {}): MobileAppStateFileSystem {
const fileByUri = new Map(Object.entries(files))
const directoryUris = new Set(['file:///docs'])
return {
documentDirectory: 'file:///docs/',
getInfoAsync: async (uri) => ({
exists: fileByUri.has(uri) || directoryUris.has(uri),
isDirectory: directoryUris.has(uri),
}),
makeDirectoryAsync: async (uri) => {
directoryUris.add(uri)
},
readAsStringAsync: async (uri) => fileByUri.get(uri) ?? '',
writeAsStringAsync: async (uri, content) => {
fileByUri.set(uri, content)
},
}
}

View File

@@ -1,126 +0,0 @@
export type MobileAppState = {
activeVaultId: string
selectedNoteId: string | null
}
export type MobileAppStateFileInfo = {
exists: boolean
isDirectory?: boolean
}
export type MobileAppStateFileSystem = {
documentDirectory: string | null
getInfoAsync: (uri: string) => Promise<MobileAppStateFileInfo>
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
readAsStringAsync: (uri: string) => Promise<string>
writeAsStringAsync: (uri: string, content: string) => Promise<void>
}
export type MobileAppStateStorage = {
load: (activeVaultId: string) => Promise<MobileAppState>
save: (state: MobileAppState) => Promise<void>
}
export function createMobileAppStateStorage(
fileSystem: MobileAppStateFileSystem,
): MobileAppStateStorage {
return {
load: async (activeVaultId) => loadMobileAppState({ activeVaultId, fileSystem }),
save: async (state) => saveMobileAppState({ fileSystem, state }),
}
}
async function loadMobileAppState({
activeVaultId,
fileSystem,
}: {
activeVaultId: string
fileSystem: MobileAppStateFileSystem
}) {
const fileUri = appStateFileUri(fileSystem)
const info = await fileSystem.getInfoAsync(fileUri)
if (!info.exists || info.isDirectory) {
return defaultMobileAppState(activeVaultId)
}
return parseMobileAppState({
activeVaultId,
content: await fileSystem.readAsStringAsync(fileUri),
})
}
async function saveMobileAppState({
fileSystem,
state,
}: {
fileSystem: MobileAppStateFileSystem
state: MobileAppState
}) {
const rootUri = appStateRootUri(fileSystem)
await ensureDirectory({ fileSystem, uri: rootUri })
await fileSystem.writeAsStringAsync(appStateFileUri(fileSystem), JSON.stringify(state))
}
function parseMobileAppState({
activeVaultId,
content,
}: {
activeVaultId: string
content: string
}) {
try {
return coerceMobileAppState({ activeVaultId, value: JSON.parse(content) })
} catch {
return defaultMobileAppState(activeVaultId)
}
}
function coerceMobileAppState({
activeVaultId,
value,
}: {
activeVaultId: string
value: unknown
}): MobileAppState {
if (!isStateRecord(value) || value.activeVaultId !== activeVaultId) {
return defaultMobileAppState(activeVaultId)
}
return {
activeVaultId,
selectedNoteId: typeof value.selectedNoteId === 'string' ? value.selectedNoteId : null,
}
}
function defaultMobileAppState(activeVaultId: string): MobileAppState {
return { activeVaultId, selectedNoteId: null }
}
async function ensureDirectory({
fileSystem,
uri,
}: {
fileSystem: MobileAppStateFileSystem
uri: string
}) {
const info = await fileSystem.getInfoAsync(uri)
if (!info.exists) {
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
}
}
function appStateFileUri(fileSystem: MobileAppStateFileSystem) {
return `${appStateRootUri(fileSystem)}/app-state.json`
}
function appStateRootUri(fileSystem: MobileAppStateFileSystem) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
return `${fileSystem.documentDirectory.replace(/\/+$/, '')}/state`
}
function isStateRecord(value: unknown): value is { activeVaultId?: unknown; selectedNoteId?: unknown } {
return typeof value === 'object' && value !== null
}

View File

@@ -1,125 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDraft, type MobileEditorDraft } from './mobileEditorDraft'
import { createMobileAutosaveQueue, type MobileAutosaveScheduler } from './mobileAutosaveQueue'
import type { MobileEditorSaveState } from './mobileEditorSaveState'
describe('mobile autosave queue', () => {
it('debounces drafts for the same note', async () => {
const scheduler = createManualScheduler()
const savedDrafts: MobileEditorDraft[] = []
const states: string[] = []
const queue = createMobileAutosaveQueue({
delayMs: 300,
scheduler,
onStateChange: (_noteId, state) => states.push(state.state),
saveDraft: async (draft) => {
savedDrafts.push(draft)
return { status: 'saved', path: `${draft.noteId}.md` }
},
})
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>First</p>'))
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>Second</p>'))
await scheduler.flush()
expect(savedDrafts).toHaveLength(1)
expect(savedDrafts[0]).toMatchObject({ canonicalMarkdown: '# Workflow\n\nSecond' })
expect(states).toEqual(['queued', 'queued', 'saving', 'saved'])
})
it('ignores stale save results when a newer draft was queued', async () => {
const scheduler = createManualScheduler()
const savedMarkdown: string[] = []
const states: MobileEditorSaveState[] = []
let resolveFirstSave: () => void = () => {
throw new Error('First save was not scheduled')
}
const queue = createMobileAutosaveQueue({
delayMs: 300,
scheduler,
onSavedDraft: (draft) => savedMarkdown.push(draft.canonicalMarkdown),
onStateChange: (_noteId, state) => states.push(state),
saveDraft: (draft) =>
draftMarkdown(draft).includes('First')
? new Promise((resolve) => {
resolveFirstSave = () => resolve({ status: 'saved', path: `${draft.noteId}.md` })
})
: Promise.resolve({ status: 'saved', path: `${draft.noteId}.md` }),
})
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>First</p>'))
await scheduler.flush()
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>Second</p>'))
resolveFirstSave()
await Promise.resolve()
await scheduler.flush()
expect(states.map((state) => state.state)).toEqual(['queued', 'saving', 'queued', 'saving', 'saved'])
expect(savedMarkdown).toEqual(['# Workflow\n\nSecond'])
})
it('can cancel pending draft saves', async () => {
const scheduler = createManualScheduler()
const savedDrafts: MobileEditorDraft[] = []
const queue = createMobileAutosaveQueue({
delayMs: 300,
scheduler,
onStateChange: () => {},
saveDraft: async (draft) => {
savedDrafts.push(draft)
return { status: 'saved', path: `${draft.noteId}.md` }
},
})
queue.enqueue(draftFor('workflow', '<h1>Workflow</h1><p>First</p>'))
queue.cancelAll()
await scheduler.flush()
expect(savedDrafts).toEqual([])
})
})
function draftFor(noteId: string, editorHtml: string) {
return createMobileEditorDraft({
note: { id: noteId, title: 'Workflow', content: '# Workflow' },
editorHtml,
})
}
function draftMarkdown(draft: MobileEditorDraft) {
if (!draft.persistable) {
throw new Error('Expected persistable test draft')
}
return draft.canonicalMarkdown
}
function createManualScheduler() {
type ManualTimer = {
callback: () => void
active: boolean
}
const timers: ManualTimer[] = []
const scheduler: MobileAutosaveScheduler & { flush: () => Promise<void> } = {
set: (callback) => {
const timer = { callback, active: true }
timers.push(timer)
return timer
},
clear: (timer) => {
const manualTimer = timer as unknown as ManualTimer
manualTimer.active = false
},
flush: async () => {
const activeTimers = timers.splice(0).filter((timer) => timer.active)
for (const timer of activeTimers) {
timer.callback()
}
await Promise.resolve()
},
}
return scheduler
}

View File

@@ -1,137 +0,0 @@
import type { MobileEditorDraft } from './mobileEditorDraft'
import type { MobileEditorDraftSaveResult } from './mobileEditorDraftSave'
import {
queuedMobileEditorSaveState,
saveResultState,
savingMobileEditorSaveState,
failedMobileEditorSaveState,
type MobileEditorSaveState,
} from './mobileEditorSaveState'
export type MobileAutosaveTimer = unknown
export type MobileAutosaveScheduler = {
set: (callback: () => void, delayMs: number) => MobileAutosaveTimer
clear: (timer: MobileAutosaveTimer) => void
}
export type MobileAutosaveQueue = {
enqueue: (draft: MobileEditorDraft) => void
cancelAll: () => void
}
export type SavedMobileEditorDraft = Extract<MobileEditorDraft, { persistable: true }>
export function createMobileAutosaveQueue({
delayMs,
onSavedDraft,
onStateChange,
saveDraft,
scheduler = nativeScheduler,
}: {
delayMs: number
onSavedDraft?: (draft: SavedMobileEditorDraft) => void
onStateChange: (noteId: string, state: MobileEditorSaveState) => void
saveDraft: (draft: MobileEditorDraft) => Promise<MobileEditorDraftSaveResult>
scheduler?: MobileAutosaveScheduler
}): MobileAutosaveQueue {
const generationByNoteId = new Map<string, number>()
const timerByNoteId = new Map<string, MobileAutosaveTimer>()
return {
enqueue: (draft) => {
const generation = nextGeneration({ draft, generationByNoteId })
clearPendingTimer({ draft, scheduler, timerByNoteId })
onStateChange(draft.noteId, queuedMobileEditorSaveState)
timerByNoteId.set(
draft.noteId,
scheduler.set(() => {
timerByNoteId.delete(draft.noteId)
void saveLatestDraft({ draft, generation, generationByNoteId, onSavedDraft, onStateChange, saveDraft })
}, delayMs),
)
},
cancelAll: () => {
for (const timer of timerByNoteId.values()) {
scheduler.clear(timer)
}
timerByNoteId.clear()
},
}
}
const nativeScheduler: MobileAutosaveScheduler = {
set: (callback, delayMs) => setTimeout(callback, delayMs),
clear: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
}
async function saveLatestDraft({
draft,
generation,
generationByNoteId,
onSavedDraft,
onStateChange,
saveDraft,
}: {
draft: MobileEditorDraft
generation: number
generationByNoteId: Map<string, number>
onSavedDraft?: (draft: SavedMobileEditorDraft) => void
onStateChange: (noteId: string, state: MobileEditorSaveState) => void
saveDraft: (draft: MobileEditorDraft) => Promise<MobileEditorDraftSaveResult>
}) {
onStateChange(draft.noteId, savingMobileEditorSaveState)
try {
const result = await saveDraft(draft)
if (isLatestGeneration({ draft, generation, generationByNoteId })) {
onStateChange(draft.noteId, saveResultState(result))
if (result.status === 'saved' && draft.persistable) {
onSavedDraft?.(draft)
}
}
} catch {
if (isLatestGeneration({ draft, generation, generationByNoteId })) {
onStateChange(draft.noteId, failedMobileEditorSaveState)
}
}
}
function nextGeneration({
draft,
generationByNoteId,
}: {
draft: MobileEditorDraft
generationByNoteId: Map<string, number>
}) {
const generation = (generationByNoteId.get(draft.noteId) ?? 0) + 1
generationByNoteId.set(draft.noteId, generation)
return generation
}
function clearPendingTimer({
draft,
scheduler,
timerByNoteId,
}: {
draft: MobileEditorDraft
scheduler: MobileAutosaveScheduler
timerByNoteId: Map<string, MobileAutosaveTimer>
}) {
const timer = timerByNoteId.get(draft.noteId)
if (timer) {
scheduler.clear(timer)
}
}
function isLatestGeneration({
draft,
generation,
generationByNoteId,
}: {
draft: MobileEditorDraft
generation: number
generationByNoteId: Map<string, number>
}) {
return generationByNoteId.get(draft.noteId) === generation
}

View File

@@ -1,128 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDraft } from './mobileEditorDraft'
import { saveMobileEditorDraft } from './mobileEditorDraftSave'
import { createMobileNoteFile } from './mobileNoteCreate'
import { saveMobileNoteFrontmatter } from './mobileNoteFrontmatterSave'
import { createMobileVaultConfig, type MobileVaultConfig } from './mobileVaultConfig'
import { createStoredMobileVaultRepository } from './mobileVaultRepository'
import { createMemoryMobileVaultStorage, type MobileVaultStorageDriver } from './mobileVaultStorage'
describe('mobile core flow smoke', () => {
it('creates, opens, edits, updates properties, and deletes an app-local note', async () => {
const vault = createVault()
const storage = createMemoryMobileVaultStorage([])
const noteId = await createNote({ storage, vault })
const openedNote = await readNote({ noteId, storage, vault })
expect(openedNote).toMatchObject({ id: noteId, title: 'Morning Plan', type: 'Note' })
await saveEditorContent({ note: openedNote, storage, vault })
await expect(storage.readMarkdownFile(vault, `${noteId}.md`)).resolves.toBe([
'---',
'title: Morning Plan',
'type: Note',
'created: 2026-05-05T08:00:00.000Z',
'---',
'# Morning Plan',
'',
'Edited agenda',
'',
'> Follow up',
].join('\n'))
await saveMobileNoteFrontmatter({
metadata: {
date: '5 May 2026',
icon: 'wrench',
status: 'Active',
tags: ['Tolaria MVP', 'mobile'],
type: 'Project',
},
noteId,
storage,
vault,
})
await expect(readNote({ noteId, storage, vault })).resolves.toMatchObject({
date: '5 May 2026',
icon: 'wrench',
status: 'Active',
tags: ['Tolaria MVP', 'mobile'],
type: 'Project',
})
await repository({ storage, vault }).deleteNote(noteId)
await expect(repository({ storage, vault }).readNote(noteId)).resolves.toBeNull()
})
})
async function createNote({
storage,
vault,
}: {
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}) {
const file = createMobileNoteFile({
now: new Date('2026-05-05T08:00:00.000Z'),
title: 'Morning Plan',
})
await storage.writeMarkdownFile(vault, file.path, file.content)
return file.path.replace(/\.md$/, '')
}
async function readNote({
noteId,
storage,
vault,
}: {
noteId: string
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}) {
const note = await repository({ storage, vault }).readNote(noteId)
if (!note) {
throw new Error(`Expected note ${noteId}`)
}
return note
}
async function saveEditorContent({
note,
storage,
vault,
}: {
note: Awaited<ReturnType<typeof readNote>>
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}) {
await saveMobileEditorDraft({
draft: createMobileEditorDraft({
note,
editorHtml: '<h1>Morning Plan</h1><p>Edited agenda</p><blockquote><p>Follow up</p></blockquote>',
}),
storage,
vault,
})
}
function repository({
storage,
vault,
}: {
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}) {
return createStoredMobileVaultRepository({ storage, vault })
}
function createVault() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}

View File

@@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest'
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
describe('mobile demo vault loading', () => {
it('does not seed demo notes into remote-backed vaults', () => {
expect(shouldSeedDemoVault({
id: 'personal',
name: 'Personal Journal',
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
})).toBe(false)
})
it('keeps local-only vaults seeded for first-run simulator QA', () => {
expect(shouldSeedDemoVault({ id: 'personal', name: 'Personal Journal' })).toBe(true)
})
})

View File

@@ -1,116 +0,0 @@
import { demoNoteSources } from './demoData'
import type { MobileEditorDraft } from './mobileEditorDraft'
import { saveMobileEditorDraft } from './mobileEditorDraftSave'
import { saveMobileNoteFrontmatter } from './mobileNoteFrontmatterSave'
import type { WritableMobileNoteFrontmatter } from './mobileNoteFrontmatterWrite'
import { createMobileNoteFile } from './mobileNoteCreate'
import {
createMobileVaultConfigFromMetadata,
defaultMobileVaultMetadata,
type MobileVaultMetadata,
} from './mobileVaultMetadata'
import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage'
import { createStoredMobileVaultRepository } from './mobileVaultRepository'
import { seedMobileVaultIfEmpty } from './mobileVaultSeed'
import type { MobileVaultFile } from './mobileVaultStorage'
import { shouldSeedDemoVault } from './mobileDemoVaultSeedPolicy'
export async function loadDemoVaultNotes(vaultMetadata = defaultMobileVaultMetadata) {
const storage = createNativeMobileVaultStorage()
const demoVault = createDemoVaultConfig(vaultMetadata)
if (shouldSeedDemoVault(vaultMetadata)) {
await seedMobileVaultIfEmpty({ files: demoVaultFiles(), storage, vault: demoVault })
await addMissingDemoVaultFiles({ files: demoVaultFiles(), storage, vault: demoVault })
}
return createStoredMobileVaultRepository({ storage, vault: demoVault }).listNotes()
}
export function saveDemoVaultDraft(draft: MobileEditorDraft, vaultMetadata = defaultMobileVaultMetadata) {
return saveMobileEditorDraft({
draft,
storage: createNativeMobileVaultStorage(),
vault: createDemoVaultConfig(vaultMetadata),
})
}
export async function createDemoVaultNote({
title,
vaultMetadata = defaultMobileVaultMetadata,
}: {
title?: string
vaultMetadata?: MobileVaultMetadata
} = {}) {
const storage = createNativeMobileVaultStorage()
const demoVault = createDemoVaultConfig(vaultMetadata)
const file = createMobileNoteFile({ title })
await storage.writeMarkdownFile(demoVault, file.path, file.content)
return createStoredMobileVaultRepository({ storage, vault: demoVault }).readNote(file.path.replace(/\.md$/, ''))
}
export async function deleteDemoVaultNote(noteId: string, vaultMetadata = defaultMobileVaultMetadata) {
const storage = createNativeMobileVaultStorage()
await createStoredMobileVaultRepository({
storage,
vault: createDemoVaultConfig(vaultMetadata),
}).deleteNote(noteId)
}
export function saveDemoVaultNoteFrontmatter({
metadata,
noteId,
vaultMetadata = defaultMobileVaultMetadata,
}: {
metadata: WritableMobileNoteFrontmatter
noteId: string
vaultMetadata?: MobileVaultMetadata
}) {
return saveDemoVaultDocumentChange({
vaultMetadata,
write: ({ storage, vault }) => saveMobileNoteFrontmatter({ metadata, noteId, storage, vault }),
})
}
function demoVaultFiles(): MobileVaultFile[] {
return demoNoteSources.map((source) => ({
path: source.filename,
content: source.content,
}))
}
function createDemoVaultConfig(vaultMetadata: MobileVaultMetadata) {
return createMobileVaultConfigFromMetadata(vaultMetadata)
}
function createDemoVaultStorageContext(vaultMetadata: MobileVaultMetadata) {
return {
storage: createNativeMobileVaultStorage(),
vault: createDemoVaultConfig(vaultMetadata),
}
}
function saveDemoVaultDocumentChange<T>({
vaultMetadata,
write,
}: {
vaultMetadata: MobileVaultMetadata
write: (context: ReturnType<typeof createDemoVaultStorageContext>) => T
}) {
return write(createDemoVaultStorageContext(vaultMetadata))
}
async function addMissingDemoVaultFiles({
files,
storage,
vault,
}: {
files: MobileVaultFile[]
storage: ReturnType<typeof createNativeMobileVaultStorage>
vault: ReturnType<typeof createDemoVaultConfig>
}) {
const existingPaths = new Set((await storage.listMarkdownFiles(vault)).map((file) => file.path))
const missingFiles = files.filter((file) => !existingPaths.has(file.path))
await Promise.all(missingFiles.map((file) => storage.writeMarkdownFile(vault, file.path, file.content)))
}

View File

@@ -1,20 +0,0 @@
import { createNativeMobileVaultStorage } from './mobileNativeVaultStorage'
import { saveMobileRawNote } from './mobileRawNoteSave'
import { createMobileVaultConfigFromMetadata, defaultMobileVaultMetadata, type MobileVaultMetadata } from './mobileVaultMetadata'
export function saveDemoVaultRawNote({
content,
noteId,
vaultMetadata = defaultMobileVaultMetadata,
}: {
content: string
noteId: string
vaultMetadata?: MobileVaultMetadata
}) {
return saveMobileRawNote({
content,
noteId,
storage: createNativeMobileVaultStorage(),
vault: createMobileVaultConfigFromMetadata(vaultMetadata),
})
}

View File

@@ -1,5 +0,0 @@
import type { MobileVaultMetadata } from './mobileVaultMetadata'
export function shouldSeedDemoVault(vaultMetadata: MobileVaultMetadata) {
return !vaultMetadata.remoteUrl?.trim()
}

View File

@@ -1,71 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mobileDerivedRelationshipGroups } from './mobileDerivedRelationships'
import type { MobileNote } from './mobileNoteProjection'
describe('mobile derived relationships', () => {
it('derives read-only inverse relationship groups from other notes', () => {
const current = note({ id: 'project/tolaria', title: 'Tolaria' })
const child = note({ belongsTo: ['[[project/tolaria|Tolaria]]'], id: 'essay', title: 'Essay' })
const related = note({ id: 'release', relatedTo: ['Tolaria'], title: 'Release' })
const custom = note({ id: 'owner', relationships: { owner: ['[[project/tolaria|Tolaria]]'] }, title: 'Owner' })
expect(mobileDerivedRelationshipGroups({ note: current, notes: [current, child, related, custom] })).toEqual([
{ label: 'Contains', targets: ['[[essay|Essay]]'] },
{ label: 'Related from', targets: ['[[release|Release]]'] },
{ label: 'Owner from', targets: ['[[owner|Owner]]'] },
])
})
it('deduplicates inverse targets inside a group', () => {
const current = note({ id: 'project/tolaria', title: 'Tolaria' })
const source = note({
belongsTo: ['[[project/tolaria|Tolaria]]', 'Tolaria'],
id: 'essay',
title: 'Essay',
})
expect(mobileDerivedRelationshipGroups({ note: current, notes: [current, source] })).toEqual([
{ label: 'Contains', targets: ['[[essay|Essay]]'] },
])
})
})
function note({
belongsTo = [],
has = [],
id,
relatedTo = [],
relationships = {},
title,
}: {
belongsTo?: string[]
has?: string[]
id: string
relatedTo?: string[]
relationships?: Record<string, string[]>
title: string
}): MobileNote {
return {
archived: false,
backlinks: [],
belongsTo,
content: `# ${title}`,
customProperties: {},
date: '',
favorite: false,
favoriteIndex: null,
has,
icon: 'file-text',
id,
modified: '',
outgoingLinks: [],
relatedTo,
relationships,
snippet: '',
status: undefined,
tags: [],
title,
type: 'Note',
words: 1,
}
}

View File

@@ -1,68 +0,0 @@
import type { MobileNote } from './mobileNoteProjection'
import { hasMobileRelationshipRef, mobileWikilinkForNote, uniqueMobileRelationshipRefs } from './mobileRelationshipRefs'
export type MobileDerivedRelationshipGroup = {
label: string
targets: string[]
}
export function mobileDerivedRelationshipGroups({
note,
notes,
}: {
note: MobileNote
notes: MobileNote[]
}) {
const groups = new Map<string, string[]>()
for (const source of notes) {
if (source.id !== note.id) {
appendDerivedGroups({ groups, note, source })
}
}
return [...groups.entries()]
.map(([label, targets]) => ({ label, targets: uniqueMobileRelationshipRefs(targets) }))
.filter((group) => group.targets.length > 0)
}
function appendDerivedGroups({
groups,
note,
source,
}: {
groups: Map<string, string[]>
note: MobileNote
source: MobileNote
}) {
appendIfLinked({ groups, label: 'Contains', note, source, values: source.belongsTo })
appendIfLinked({ groups, label: 'Related from', note, source, values: source.relatedTo })
appendIfLinked({ groups, label: 'Part of', note, source, values: source.has })
for (const [key, values] of Object.entries(source.relationships)) {
appendIfLinked({ groups, label: `${formatRelationshipLabel(key)} from`, note, source, values })
}
}
function appendIfLinked({
groups,
label,
note,
source,
values,
}: {
groups: Map<string, string[]>
label: string
note: MobileNote
source: MobileNote
values: string[]
}) {
if (
hasMobileRelationshipRef({ target: mobileWikilinkForNote(note), values })
|| hasMobileRelationshipRef({ target: note.title, values })
) {
groups.set(label, [...groups.get(label) ?? [], mobileWikilinkForNote(source)])
}
}
function formatRelationshipLabel(key: string) {
return key.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
}

View File

@@ -1,114 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDocument, createMobileEditorHtml } from './mobileEditorDocument'
describe('mobile editor document', () => {
it('strips frontmatter and title heading from the displayed editor body', () => {
expect(
createMobileEditorDocument({
id: 'workflow',
title: 'Workflow Orchestration Essay',
content: [
'---',
'type: Essay',
'---',
'',
'# Workflow Orchestration Essay',
'',
'The current narrative: everything routed through an LLM.',
].join('\n'),
}),
).toEqual({
leadingTitle: 'Workflow Orchestration Essay',
blocks: [
{
id: '0:The current narrative: everything routed through an LLM.',
kind: 'paragraph',
text: 'The current narrative: everything routed through an LLM.',
},
],
})
})
it('keeps colon paragraphs instead of treating them as frontmatter', () => {
const document = createMobileEditorDocument({
id: 'monday',
title: 'Notes for Monday',
content: '# Notes for Monday\n\nBottom line up front: ship the smallest useful slice.',
})
expect(document.blocks).toEqual([
{
id: '0:Bottom line up front: ship the smallest useful slice.',
kind: 'paragraph',
text: 'Bottom line up front: ship the smallest useful slice.',
},
])
})
it('normalizes markdown bullets for the native placeholder surface', () => {
const document = createMobileEditorDocument({
id: 'plan',
title: 'Plan',
content: '# Plan\n\n- Sidebar\n* Note list',
})
expect(document.blocks).toEqual([
{
id: '0:- Sidebar',
kind: 'bullet',
text: 'Sidebar',
},
{
id: '1:* Note list',
kind: 'bullet',
text: 'Note list',
},
])
})
it('creates escaped HTML for TenTap initial content', () => {
const html = createMobileEditorHtml({
leadingTitle: 'Tolaria <mobile>',
blocks: [
{
id: '0:Use TenTap',
kind: 'paragraph',
text: 'Use TenTap & keep markdown durable',
},
{
id: '1:- Escape quotes',
kind: 'bullet',
text: 'Escape "quotes"',
},
],
})
expect(html).toBe(
'<h1>Tolaria &lt;mobile&gt;</h1><p>Use TenTap &amp; keep markdown durable</p><ul><li>Escape &quot;quotes&quot;</li></ul>',
)
})
it('does not reinsert an H1 after the note body no longer starts with the title heading', () => {
const document = createMobileEditorDocument({
id: 'untitled',
title: 'Untitled',
content: 'Body without a leading heading',
})
expect(document.leadingTitle).toBeNull()
expect(createMobileEditorHtml(document)).toBe('<p>Body without a leading heading</p>')
})
it('renders wikilinks as clickable rich links in the editor HTML', () => {
const document = createMobileEditorDocument({
id: 'links',
title: 'Links',
content: '# Links\n\nSee [[mobile-roadmap|Mobile Roadmap]] next.',
})
expect(createMobileEditorHtml(document)).toContain(
'<a data-tolaria-wikilink="true" href="tolaria-note:mobile-roadmap">Mobile Roadmap</a>',
)
})
})

View File

@@ -1,89 +0,0 @@
import { splitFrontmatter } from '@tolaria/markdown'
export type MobileEditorBlock = {
id: string
kind: 'bullet' | 'paragraph'
text: string
}
export type MobileEditorDocument = {
leadingTitle: string | null
blocks: MobileEditorBlock[]
}
export type MobileEditorDocumentInput = {
id: string
title: string
content: string
}
export function createMobileEditorDocument(input: MobileEditorDocumentInput): MobileEditorDocument {
const [, body] = splitFrontmatter(input.content)
return {
leadingTitle: leadingTitle({ body, title: input.title }),
blocks: createBlocks({ body, title: input.title }),
}
}
export function createMobileEditorHtml(document: MobileEditorDocument) {
return `${titleHtml(document.leadingTitle)}${document.blocks.map(blockToHtml).join('') || '<p></p>'}`
}
function leadingTitle({ body, title }: { body: string; title: string }) {
const firstLine = body.split('\n').find((line) => line.trim().length > 0)?.trim()
return firstLine && isTitleHeading({ line: firstLine, title }) ? title : null
}
function createBlocks({ body, title }: { body: string; title: string }) {
return body
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !isTitleHeading({ line, title }))
.map((line, index) => createBlock({ index, line }))
}
function createBlock({ index, line }: { index: number; line: string }): MobileEditorBlock {
const bulletText = bulletContent({ line })
return {
id: `${index}:${line}`,
kind: bulletText ? 'bullet' : 'paragraph',
text: bulletText ?? line,
}
}
function bulletContent({ line }: { line: string }) {
const match = /^[-*]\s+(.+)$/.exec(line)
return match?.[1] ?? null
}
function isTitleHeading({ line, title }: { line: string; title: string }) {
return line === `# ${title}`
}
function blockToHtml(block: MobileEditorBlock) {
const text = inlineTextToHtml(block.text)
return block.kind === 'bullet' ? `<ul><li>${text}</li></ul>` : `<p>${text}</p>`
}
function titleHtml(title: string | null) {
return title ? `<h1>${escapeHtml({ value: title })}</h1>` : ''
}
function inlineTextToHtml(value: string) {
return escapeHtml({ value }).replace(/\[\[([^[\]]+?)\]\]/g, (_match, inner: string) => {
const [target, alias] = inner.split('|')
const label = alias?.trim() || target.trim()
return `<a data-tolaria-wikilink="true" href="tolaria-note:${encodeURIComponent(target.trim())}">${label}</a>`
})
}
function escapeHtml({ value }: { value: string }) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}

View File

@@ -1,305 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDraft } from './mobileEditorDraft'
describe('mobile editor draft', () => {
it('serializes supported TenTap HTML into canonical Markdown', () => {
expect(
createMobileEditorDraft({
note: {
id: 'workflow',
title: 'Workflow',
content: '# Workflow\n\nOriginal markdown',
},
editorHtml: '<h1>Workflow</h1><p>Edited content</p><ul><li>First</li><li>Second</li></ul>',
}),
).toEqual({
noteId: 'workflow',
sourceMarkdown: '# Workflow\n\nOriginal markdown',
editorHtml: '<h1>Workflow</h1><p>Edited content</p><ul><li>First</li><li>Second</li></ul>',
persistable: true,
canonicalMarkdown: '# Workflow\n\nEdited content\n\n- First\n- Second',
})
})
it('preserves source frontmatter outside the edited body', () => {
const draft = createMobileEditorDraft({
note: {
id: 'workflow',
title: 'Workflow',
content: '---\ntype: Essay\n---\n\n# Workflow\n\nOriginal markdown',
},
editorHtml: '<h1>Workflow</h1><p>Edited content</p>',
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: '---\ntype: Essay\n---\n# Workflow\n\nEdited content',
})
})
it('decodes escaped text before writing Markdown', () => {
const draft = createMobileEditorDraft({
note: {
id: 'symbols',
title: 'Symbols',
content: '# Symbols',
},
editorHtml: '<h1>Symbols</h1><p>Use &lt;tags&gt; &amp; &quot;quotes&quot;, &#33;&#x3f; and non&nbsp;breaking space</p>',
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: '# Symbols\n\nUse <tags> & "quotes", !? and non breaking space',
})
})
it('serializes headings, ordered lists, and inline marks', () => {
const draft = createMobileEditorDraft({
note: {
id: 'formatting',
title: 'Formatting',
content: '# Formatting',
},
editorHtml: [
'<h2>Section</h2>',
'<p>Use <strong>bold</strong>, <em>emphasis</em>, <code>code</code>, and <a href="https://tolaria.app">links</a>.</p>',
'<ol><li>First</li><li>Second</li></ol>',
].join(''),
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: [
'## Section',
'',
'Use **bold**, *emphasis*, `code`, and [links](https://tolaria.app).',
'',
'1. First',
'1. Second',
].join('\n'),
})
})
it('serializes safe link destinations and decodes escaped link URLs', () => {
const draft = createMobileEditorDraft({
note: {
id: 'links',
title: 'Links',
content: '# Links',
},
editorHtml: [
'<p><a href="https://tolaria.app?ref=notes&amp;device=ios">Website</a></p>',
'<p><a href="mailto:hello@tolaria.app">Email</a></p>',
'<p><a href="notes/workflow.md">Relative note</a></p>',
].join(''),
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: [
'[Website](https://tolaria.app?ref=notes&device=ios)',
'',
'[Email](mailto:hello@tolaria.app)',
'',
'[Relative note](notes/workflow.md)',
].join('\n'),
})
})
it('serializes rich wikilinks back to desktop-compatible wikilink markdown', () => {
const draft = createMobileEditorDraft({
note: {
id: 'links',
title: 'Links',
content: '# Links',
},
editorHtml: '<p>See <a data-tolaria-wikilink="true" href="tolaria-note:mobile-roadmap">Mobile Roadmap</a>.</p>',
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: 'See [[mobile-roadmap|Mobile Roadmap]].',
})
})
it('serializes blockquotes, code blocks, and strikethrough', () => {
const draft = createMobileEditorDraft({
note: {
id: 'formatting',
title: 'Formatting',
content: '# Formatting',
},
editorHtml: [
'<blockquote><p>Quoted idea</p><p>Second line</p></blockquote>',
'<pre><code class="language-ts">const value = &lt;string&gt;input</code></pre>',
'<p>Keep <s>stale</s> text visible.</p>',
].join(''),
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: [
'> Quoted idea',
'> Second line',
'',
'```ts',
'const value = <string>input',
'```',
'',
'Keep ~~stale~~ text visible.',
].join('\n'),
})
})
it('serializes horizontal rules from TenTap HTML', () => {
const draft = createMobileEditorDraft({
note: {
id: 'break',
title: 'Break',
content: '# Break',
},
editorHtml: '<p>Before</p><hr><p>After</p>',
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: 'Before\n\n---\n\nAfter',
})
})
it('serializes TenTap-style task list items', () => {
const draft = createMobileEditorDraft({
note: {
id: 'tasks',
title: 'Tasks',
content: '# Tasks',
},
editorHtml: [
'<ul data-type="taskList">',
'<li data-checked="true"><label><input type="checkbox" checked=""></label><div><p>Done</p></div></li>',
'<li data-checked="false"><label><input type="checkbox"></label><div><p>Todo</p></div></li>',
'</ul>',
].join(''),
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: '- [x] Done\n- [ ] Todo',
})
})
it('blocks unsupported HTML instead of persisting unknown editor output', () => {
expect(
createMobileEditorDraft({
note: {
id: 'workflow',
title: 'Workflow',
content: '# Workflow\n\nOriginal markdown',
},
editorHtml: '<figure><figcaption>Not yet supported</figcaption></figure>',
}),
).toEqual({
noteId: 'workflow',
sourceMarkdown: '# Workflow\n\nOriginal markdown',
editorHtml: '<figure><figcaption>Not yet supported</figcaption></figure>',
persistable: false,
blockedReason: 'unsupportedEditorHtml',
})
})
it('blocks unsafe link destinations instead of persisting risky Markdown', () => {
expect(
createMobileEditorDraft({
note: {
id: 'links',
title: 'Links',
content: '# Links',
},
editorHtml: '<p><a href="javascript:alert(1)">Unsafe</a></p>',
}),
).toMatchObject({
noteId: 'links',
persistable: false,
blockedReason: 'unsupportedEditorHtml',
})
})
it('serializes simple TenTap tables as Markdown tables', () => {
expect(
createMobileEditorDraft({
note: {
id: 'table',
title: 'Table',
content: '# Table',
},
editorHtml: [
'<table><tbody>',
'<tr><th><p>Name</p></th><th><p>Status</p></th></tr>',
'<tr><td><p>Tolaria</p></td><td><p>Ready &amp; synced</p></td></tr>',
'<tr><td><p>Pipe</p></td><td><p>A | B</p></td></tr>',
'</tbody></table>',
].join(''),
}),
).toMatchObject({
noteId: 'table',
persistable: true,
canonicalMarkdown: [
'| Name | Status |',
'| --- | --- |',
'| Tolaria | Ready & synced |',
'| Pipe | A \\| B |',
].join('\n'),
})
})
it('blocks malformed tables instead of guessing columns', () => {
expect(
createMobileEditorDraft({
note: {
id: 'table',
title: 'Table',
content: '# Table',
},
editorHtml: '<table><tbody><tr><td>Name</td><td>Status</td></tr><tr><td>Tolaria</td></tr></tbody></table>',
}),
).toMatchObject({
noteId: 'table',
persistable: false,
blockedReason: 'unsupportedEditorHtml',
})
})
it('serializes safe image attachments inside supported blocks', () => {
expect(
createMobileEditorDraft({
note: {
id: 'image',
title: 'Image',
content: '# Image',
},
editorHtml: '<p>Before</p><p><img src="attachments/sketch.png" alt="Interface sketch"></p><p>After</p>',
}),
).toMatchObject({
noteId: 'image',
persistable: true,
canonicalMarkdown: 'Before\n\n![Interface sketch](attachments/sketch.png)\n\nAfter',
})
})
it('blocks transient or unsafe image sources inside otherwise supported blocks', () => {
expect(
createMobileEditorDraft({
note: {
id: 'image',
title: 'Image',
content: '# Image',
},
editorHtml: '<p><img src="blob:https://tolaria.app/preview" alt="Attachment"></p>',
}),
).toMatchObject({
noteId: 'image',
persistable: false,
blockedReason: 'unsupportedEditorHtml',
})
})
})

View File

@@ -1,67 +0,0 @@
import { splitFrontmatter } from '@tolaria/markdown'
import type { MobileEditorDocumentInput } from './mobileEditorDocument'
import { serializeSupportedMobileEditorHtml } from './mobileEditorHtmlMarkdown'
export type MobileEditorDraft =
| {
noteId: string
sourceMarkdown: string
editorHtml: string
persistable: true
canonicalMarkdown: string
}
| {
noteId: string
sourceMarkdown: string
editorHtml: string
persistable: false
blockedReason: 'unsupportedEditorHtml'
}
export function createMobileEditorDraft({
editorHtml,
note,
}: {
editorHtml: string
note: MobileEditorDocumentInput
}): MobileEditorDraft {
const markdownBody = serializeSupportedMobileEditorHtml({ editorHtml })
if (!markdownBody) {
return createBlockedDraft({ editorHtml, note })
}
return {
noteId: note.id,
sourceMarkdown: note.content,
editorHtml,
persistable: true,
canonicalMarkdown: withFrontmatter({ markdownBody, sourceMarkdown: note.content }),
}
}
function createBlockedDraft({
editorHtml,
note,
}: {
editorHtml: string
note: MobileEditorDocumentInput
}): MobileEditorDraft {
return {
noteId: note.id,
sourceMarkdown: note.content,
editorHtml,
persistable: false,
blockedReason: 'unsupportedEditorHtml',
}
}
function withFrontmatter({
markdownBody,
sourceMarkdown,
}: {
markdownBody: string
sourceMarkdown: string
}) {
const [frontmatter] = splitFrontmatter(sourceMarkdown)
return frontmatter ? `${frontmatter}${markdownBody}` : markdownBody
}

View File

@@ -1,48 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createMobileEditorDraft } from './mobileEditorDraft'
import { saveMobileEditorDraft } from './mobileEditorDraftSave'
import { createMobileVaultConfig } from './mobileVaultConfig'
import { createMemoryMobileVaultStorage } from './mobileVaultStorage'
const vault = createVault()
describe('mobile editor draft save', () => {
it('writes persistable editor drafts as canonical Markdown', async () => {
const storage = createMemoryMobileVaultStorage([])
const draft = createMobileEditorDraft({
note: { id: 'notes/workflow', title: 'Workflow', content: '# Workflow' },
editorHtml: '<h1>Workflow</h1><p>Edited</p>',
})
await expect(saveMobileEditorDraft({ draft, storage, vault })).resolves.toEqual({
status: 'saved',
path: 'notes/workflow.md',
})
await expect(storage.readMarkdownFile(vault, 'notes/workflow.md')).resolves.toBe('# Workflow\n\nEdited')
})
it('does not write blocked editor drafts', async () => {
const storage = createMemoryMobileVaultStorage([])
const draft = createMobileEditorDraft({
note: { id: 'workflow', title: 'Workflow', content: '# Workflow' },
editorHtml: '<figure><figcaption>Unsupported</figcaption></figure>',
})
await expect(saveMobileEditorDraft({ draft, storage, vault })).resolves.toEqual({
status: 'blocked',
reason: 'unsupportedEditorHtml',
})
await expect(storage.listMarkdownFiles(vault)).resolves.toEqual([])
})
})
function createVault() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}

View File

@@ -1,36 +0,0 @@
import type { MobileEditorDraft } from './mobileEditorDraft'
import type { MobileVaultConfig } from './mobileVaultConfig'
import type { MobileVaultStorageDriver } from './mobileVaultStorage'
export type MobileEditorDraftSaveResult =
| {
status: 'saved'
path: string
}
| {
status: 'blocked'
reason: 'unsupportedEditorHtml'
}
export async function saveMobileEditorDraft({
draft,
storage,
vault,
}: {
draft: MobileEditorDraft
storage: MobileVaultStorageDriver
vault: MobileVaultConfig
}): Promise<MobileEditorDraftSaveResult> {
if (!draft.persistable) {
return { status: 'blocked', reason: draft.blockedReason }
}
const path = draftPath(draft)
await storage.writeMarkdownFile(vault, path, draft.canonicalMarkdown)
return { status: 'saved', path }
}
function draftPath(draft: MobileEditorDraft) {
return `${draft.noteId}.md`
}

View File

@@ -1,315 +0,0 @@
import {
canSerializeMobileEditorTable,
isMobileEditorTableBlock,
mobileEditorTableMarkdown,
} from './mobileEditorTableMarkdown'
import { decodeMobileHtmlEntities } from './mobileHtmlEntities'
type EditorHtmlInput = {
editorHtml: string
}
type HtmlInput = {
html: string
}
type ListItemInput = HtmlInput & {
ordered: boolean
}
const supportedHtmlTags = new Set([
'a',
'b',
'blockquote',
'br',
'code',
'del',
'div',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'img',
'input',
'label',
'li',
'ol',
'p',
'pre',
's',
'strike',
'strong',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'ul',
])
export function serializeSupportedMobileEditorHtml(input: EditorHtmlInput) {
const html = normalizeBlockSpacing(input)
const blocks = html.match(/<(h[1-6]|p|ul|ol|blockquote|pre|table)(?:\s[^>]*)?>[\s\S]*?<\/\1>|<hr(?:\s[^>]*)?\s*\/?>/gi)
if (!blocks) {
return null
}
if (!canSerializeBlocks({ blocks, html })) {
return null
}
return blocks.map((block) => serializeBlock({ html: block })).join('\n\n')
}
function canSerializeBlocks({
blocks,
html,
}: {
blocks: RegExpMatchArray
html: string
}) {
return blocks.join('') === html && !blocks.some((block) => blocksUnsafeEditorOutput({ html: block }))
}
function serializeBlock(input: HtmlInput) {
const headingLevel = headingMarkdownLevel(input)
if (headingLevel) {
return `${'#'.repeat(headingLevel)} ${inlineMarkdown(input)}`
}
if (isListBlock(input)) {
return listItemMarkdown(input).join('\n')
}
if (isBlockquote(input)) {
return blockquoteMarkdown(input)
}
if (isCodeBlock(input)) {
return codeBlockMarkdown(input)
}
if (isHorizontalRule(input)) {
return '---'
}
if (isMobileEditorTableBlock(input)) {
return mobileEditorTableMarkdown(input)
}
return inlineMarkdown(input)
}
function normalizeBlockSpacing(input: EditorHtmlInput) {
return input.editorHtml.trim().replace(/>\s+</g, '><')
}
function headingMarkdownLevel(input: HtmlInput) {
const match = input.html.match(/^<h([1-6])/i)
return match ? Number(match[1]) : null
}
function isListBlock(input: HtmlInput) {
return input.html.match(/^<(ul|ol)/i)
}
function isBlockquote(input: HtmlInput) {
return input.html.match(/^<blockquote/i)
}
function isCodeBlock(input: HtmlInput) {
return input.html.match(/^<pre/i)
}
function isHorizontalRule(input: HtmlInput) {
return input.html.match(/^<hr/i)
}
function listItemMarkdown(input: HtmlInput) {
const ordered = input.html.match(/^<ol/i)
return [...input.html.matchAll(/<li(?:\s[^>]*)?>([\s\S]*?)<\/li>/gi)].map((match) =>
formatListItem({ ordered: Boolean(ordered), html: match[0] }),
)
}
function formatListItem(input: ListItemInput) {
const taskMarker = markdownTaskMarker(input)
const prefix = taskMarker ? `- ${taskMarker}` : input.ordered ? '1.' : '-'
return `${prefix} ${inlineMarkdown(input)}`
}
function markdownTaskMarker(input: HtmlInput) {
if (input.html.match(/data-checked=["']true/i) || input.html.match(/<input[^>]+checked/i)) {
return '[x]'
}
if (input.html.match(/data-checked=["']false/i) || input.html.match(/<input[^>]+type=["']checkbox/i)) {
return '[ ]'
}
return null
}
function blockquoteMarkdown(input: HtmlInput) {
const paragraphLines = [...input.html.matchAll(/<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/gi)].map((match) =>
inlineMarkdown({ html: match[0] }),
)
const lines = paragraphLines.length > 0 ? paragraphLines : [inlineMarkdown(input)]
return lines.map((line) => `> ${line}`).join('\n')
}
function codeBlockMarkdown(input: HtmlInput) {
return [
`\`\`\`${codeBlockLanguage(input)}`,
decodeMobileHtmlEntities({ text: codeBlockText(input) }).trimEnd(),
'```',
].join('\n')
}
function codeBlockLanguage(input: HtmlInput) {
return input.html.match(/language-([A-Za-z0-9_-]+)/)?.[1] ?? ''
}
function codeBlockText(input: HtmlInput) {
const code = input.html.match(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/i)?.[1] ?? input.html
return stripRemainingTags(code.replace(/<br\s*\/?>/gi, '\n'))
}
function inlineMarkdown(input: HtmlInput) {
return decodeMobileHtmlEntities({ text: stripRemainingTags(markInlineHtml(input)).trim() })
}
function markInlineHtml(input: HtmlInput) {
return input.html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<img\b[^>]*>/gi, (tag) => imageMarkdown({ tag }) ?? tag)
.replace(/<(strong|b)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '**$2**')
.replace(/<(em|i)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '*$2*')
.replace(/<(s|strike|del)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '~~$2~~')
.replace(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/gi, '`$1`')
.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, (tag, label) => linkMarkdown({ tag, label }) ?? tag)
}
function containsUnsupportedTag(input: HtmlInput) {
return [...input.html.matchAll(/<\/?([a-z0-9-]+)/gi)]
.some((match) => !supportedHtmlTags.has(match[1].toLowerCase()))
}
function blocksUnsafeEditorOutput(input: HtmlInput) {
return containsUnsupportedTag(input) || containsUnsafeImage(input) || containsUnsafeLink(input) || containsUnsafeTable(input)
}
function containsUnsafeImage(input: HtmlInput) {
return [...input.html.matchAll(/<img\b[^>]*>/gi)].some((match) => !imageMarkdown({ tag: match[0] }))
}
function containsUnsafeLink(input: HtmlInput) {
return [...input.html.matchAll(/<a\b[^>]*>/gi)].some((match) => !linkMarkdown({ tag: match[0], label: '' }))
}
function containsUnsafeTable(input: HtmlInput) {
return Boolean(isMobileEditorTableBlock(input)) && !canSerializeMobileEditorTable(input)
}
function imageMarkdown(input: { tag: string }) {
const src = imageSource(input)
if (!src) {
return null
}
const alt = htmlAttribute({ tag: input.tag, name: 'alt' }) ?? ''
return `![${decodeMobileHtmlEntities({ text: alt })}](${decodeMobileHtmlEntities({ text: src })})`
}
function imageSource(input: { tag: string }) {
const src = htmlAttribute({ tag: input.tag, name: 'src' })
return src && isPersistableImageSource({ src }) ? src : null
}
function linkMarkdown(input: { tag: string; label: string }) {
const wikilink = wikilinkMarkdown(input)
if (wikilink) {
return wikilink
}
const href = linkDestination(input)
return href ? `[${input.label}](${href})` : null
}
function wikilinkMarkdown(input: { tag: string; label: string }) {
const href = htmlAttribute({ tag: input.tag, name: 'href' })
if (!href?.startsWith('tolaria-note:')) {
return null
}
const target = decodeURIComponent(href.slice('tolaria-note:'.length)).trim()
const label = decodeMobileHtmlEntities({ text: input.label }).trim()
if (!target) {
return null
}
return label && label !== target ? `[[${target}|${label}]]` : `[[${target}]]`
}
function linkDestination(input: { tag: string }) {
const href = htmlAttribute({ tag: input.tag, name: 'href' })
return href && isPersistableLinkDestination({ href }) ? decodeMobileHtmlEntities({ text: href }) : null
}
function htmlAttribute(input: { tag: string; name: string }) {
const match = input.tag.match(new RegExp(`${input.name}=["']([^"']+)["']`, 'i'))
return match?.[1] ?? null
}
function isPersistableImageSource(input: { src: string }) {
if (input.src.match(/[\n\r]/)) {
return false
}
return isRemoteImageSource(input) || isRelativeImageSource(input)
}
function isRemoteImageSource(input: { src: string }) {
return input.src.startsWith('https://') || input.src.startsWith('http://')
}
function isRelativeImageSource(input: { src: string }) {
return !input.src.startsWith('/') && !input.src.startsWith('//') && !input.src.match(/^[A-Za-z][A-Za-z0-9+.-]*:/)
}
function isPersistableLinkDestination(input: { href: string }) {
const href = decodeMobileHtmlEntities({ text: input.href })
if (href.match(/[\n\r]/)) {
return false
}
return isRemoteLinkDestination({ href }) || isMailLinkDestination({ href }) || isRelativeLinkDestination({ href }) || isWikilinkDestination({ href })
}
function isRemoteLinkDestination(input: { href: string }) {
return input.href.startsWith('https://') || input.href.startsWith('http://')
}
function isMailLinkDestination(input: { href: string }) {
return input.href.startsWith('mailto:')
}
function isRelativeLinkDestination(input: { href: string }) {
return !input.href.startsWith('/') && !input.href.startsWith('//') && !input.href.match(/^[A-Za-z][A-Za-z0-9+.-]*:/)
}
function isWikilinkDestination(input: { href: string }) {
return input.href.startsWith('tolaria-note:') && input.href.length > 'tolaria-note:'.length
}
function stripRemainingTags(value: string) {
return value.replace(/<[^>]+>/g, '')
}

View File

@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest'
import { parseEditorMessage } from './mobileEditorMessages'
describe('mobile editor messages', () => {
it('parses empty wikilink queries with cursor geometry', () => {
expect(parseEditorMessage(JSON.stringify({
frame: { bottom: 124, left: 48 },
query: '',
type: 'wikilinkQuery',
}))).toEqual({
frame: { bottom: 124, left: 48 },
query: '',
type: 'wikilinkQuery',
})
})
it('ignores invalid wikilink query geometry', () => {
expect(parseEditorMessage(JSON.stringify({
frame: { bottom: '124', left: 48 },
query: 'roadmap',
type: 'wikilinkQuery',
}))).toEqual({
frame: null,
query: 'roadmap',
type: 'wikilinkQuery',
})
})
it('parses hardware Tab list indentation messages', () => {
expect(parseEditorMessage(JSON.stringify({
direction: 'in',
type: 'listIndent',
}))).toEqual({
direction: 'in',
type: 'listIndent',
})
expect(parseEditorMessage(JSON.stringify({
direction: 'out',
type: 'listIndent',
}))).toEqual({
direction: 'out',
type: 'listIndent',
})
})
it('rejects malformed list indentation messages', () => {
expect(parseEditorMessage(JSON.stringify({
direction: 'sideways',
type: 'listIndent',
}))).toBeNull()
})
})

View File

@@ -1,88 +0,0 @@
export type MobileEditorMessage =
| { target: string; type: 'openWikilink' }
| { command: 'fileNewNote'; type: 'shortcut' }
| { direction: 'in' | 'out'; type: 'listIndent' }
| { frame: MobileEditorWikilinkFrame | null; query: string | null; type: 'wikilinkQuery' }
export type MobileEditorWikilinkFrame = {
bottom: number
left: number
}
export function parseEditorMessage(data: string): MobileEditorMessage | null {
try {
return normalizeEditorMessage(JSON.parse(data))
} catch {
return null
}
}
function normalizeEditorMessage(value: unknown): MobileEditorMessage | null {
if (!isMessageRecord(value)) {
return null
}
if (isWikilinkQueryMessage(value)) {
return { frame: wikilinkFrame(value.frame), query: value.query, type: 'wikilinkQuery' }
}
if (isListIndentMessage(value)) {
return { direction: value.direction, type: 'listIndent' }
}
if (value.type === 'openWikilink' && typeof value.target === 'string') {
return { target: value.target, type: 'openWikilink' }
}
if (value.type === 'shortcut' && value.command === 'fileNewNote') {
return { command: 'fileNewNote', type: 'shortcut' }
}
return null
}
function isMessageRecord(value: unknown): value is {
command?: unknown
direction?: unknown
frame?: unknown
query?: unknown
target?: unknown
type?: unknown
} {
return typeof value === 'object' && value !== null
}
function isWikilinkQueryMessage(value: {
frame?: unknown
query?: unknown
type?: unknown
}): value is {
frame?: unknown
query: string | null
type: 'wikilinkQuery'
} {
return value.type === 'wikilinkQuery'
&& (typeof value.query === 'string' || value.query === null)
}
function wikilinkFrame(value: unknown): MobileEditorWikilinkFrame | null {
if (!isFrameRecord(value)) {
return null
}
return { bottom: value.bottom, left: value.left }
}
function isFrameRecord(value: unknown): value is MobileEditorWikilinkFrame {
return typeof value === 'object'
&& value !== null
&& typeof (value as { bottom?: unknown }).bottom === 'number'
&& typeof (value as { left?: unknown }).left === 'number'
}
function isListIndentMessage(value: {
direction?: unknown
type?: unknown
}): value is {
direction: 'in' | 'out'
type: 'listIndent'
} {
return value.type === 'listIndent'
&& (value.direction === 'in' || value.direction === 'out')
}

View File

@@ -1,28 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
failedMobileEditorSaveState,
idleMobileEditorSaveState,
queuedMobileEditorSaveState,
saveResultState,
savingMobileEditorSaveState,
} from './mobileEditorSaveState'
describe('mobile editor save state', () => {
it('provides stable labels for direct save states', () => {
expect(idleMobileEditorSaveState.label).toBe('Ready')
expect(queuedMobileEditorSaveState.label).toBe('Edited')
expect(savingMobileEditorSaveState.label).toBe('Saving')
expect(failedMobileEditorSaveState.label).toBe('Save failed')
})
it('derives visible state from save results', () => {
expect(saveResultState({ status: 'saved', path: 'workflow.md' })).toEqual({
state: 'saved',
label: 'Saved',
})
expect(saveResultState({ status: 'blocked', reason: 'unsupportedEditorHtml' })).toEqual({
state: 'blocked',
label: 'Blocked',
})
})
})

View File

@@ -1,53 +0,0 @@
import type { MobileEditorDraftSaveResult } from './mobileEditorDraftSave'
export type MobileEditorSaveState =
| {
state: 'idle'
label: 'Ready'
}
| {
state: 'queued'
label: 'Edited'
}
| {
state: 'saving'
label: 'Saving'
}
| {
state: 'saved'
label: 'Saved'
}
| {
state: 'blocked'
label: 'Blocked'
}
| {
state: 'failed'
label: 'Save failed'
}
export const idleMobileEditorSaveState: MobileEditorSaveState = {
state: 'idle',
label: 'Ready',
}
export const queuedMobileEditorSaveState: MobileEditorSaveState = {
state: 'queued',
label: 'Edited',
}
export const savingMobileEditorSaveState: MobileEditorSaveState = {
state: 'saving',
label: 'Saving',
}
export const failedMobileEditorSaveState: MobileEditorSaveState = {
state: 'failed',
label: 'Save failed',
}
export function saveResultState(result: MobileEditorDraftSaveResult): MobileEditorSaveState {
return result.status === 'saved'
? { state: 'saved', label: 'Saved' }
: { state: 'blocked', label: 'Blocked' }
}

View File

@@ -1,62 +0,0 @@
import { decodeMobileHtmlEntities } from './mobileHtmlEntities'
type HtmlInput = {
html: string
}
type TableRow = {
cells: string[]
}
export function isMobileEditorTableBlock(input: HtmlInput) {
return input.html.match(/^<table/i)
}
export function canSerializeMobileEditorTable(input: HtmlInput) {
const rows = tableRows(input)
const columnCount = rows[0]?.cells.length ?? 0
return columnCount > 0 && rows.every((row) => row.cells.length === columnCount)
}
export function mobileEditorTableMarkdown(input: HtmlInput) {
const rows = tableRows(input)
const header = rows[0]?.cells ?? []
const body = rows.slice(1).map(tableRowMarkdown)
return [
tableRowMarkdown({ cells: header }),
tableSeparator({ columnCount: header.length }),
...body,
].join('\n')
}
function tableRows(input: HtmlInput) {
return [...input.html.matchAll(/<tr(?:\s[^>]*)?>([\s\S]*?)<\/tr>/gi)]
.map((match) => tableRow({ html: match[1] }))
}
function tableRow(input: HtmlInput): TableRow {
return {
cells: [...input.html.matchAll(/<t[hd](?:\s[^>]*)?>([\s\S]*?)<\/t[hd]>/gi)]
.map((match) => tableCellMarkdown({ html: match[1] })),
}
}
function tableCellMarkdown(input: HtmlInput) {
return decodeMobileHtmlEntities({ text: stripCellTags(input).trim() })
.replace(/\s+/g, ' ')
.replace(/\|/g, '\\|')
}
function tableRowMarkdown(input: TableRow) {
return `| ${input.cells.join(' | ')} |`
}
function tableSeparator(input: { columnCount: number }) {
return tableRowMarkdown({ cells: Array.from({ length: input.columnCount }, () => '---') })
}
function stripCellTags(input: HtmlInput) {
return input.html.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '')
}

View File

@@ -1,150 +0,0 @@
export const mobileEditorSetupScript = `
document.documentElement.lang = navigator.language || "en";
document.addEventListener("keydown", function(event) {
if (isFileNewShortcut(event)) {
event.preventDefault();
postEditorMessage({ type: "shortcut", command: "fileNewNote" });
return;
}
if (!isTabInsideEditor(event)) return;
event.preventDefault();
postEditorMessage({
type: "listIndent",
direction: event.shiftKey ? "out" : "in"
});
}, true);
document.addEventListener("click", function(event) {
var link = event.target && event.target.closest && event.target.closest("a[href^='tolaria-note:']");
if (!link) return;
event.preventDefault();
postEditorMessage({
type: "openWikilink",
target: decodeURIComponent(String(link.getAttribute("href") || "").replace(/^tolaria-note:/, ""))
});
}, true);
function isFileNewShortcut(event) {
return (event.metaKey || event.ctrlKey)
&& !event.altKey
&& String(event.key).toLowerCase() === "n";
}
function isTabInsideEditor(event) {
if (event.key !== "Tab") return false;
var selection = window.getSelection();
var node = selection && selection.anchorNode;
return Boolean(node && containingEditor(node));
}
function containingEditor(node) {
var editor = document.querySelector(".ProseMirror");
var container = node.nodeType === 1 ? node : node.parentNode;
return editor && container && editor.contains(container);
}
function activeWikilinkQuery() {
var selection = window.getSelection();
if (!isCollapsedTextSelection(selection)) return null;
if (!containingEditor(selection.anchorNode)) return null;
return wikilinkQueryBeforeCursor(selection);
}
function isCollapsedTextSelection(selection) {
return Boolean(selection
&& selection.anchorNode
&& selection.anchorNode.nodeType === 3
&& selection.rangeCount > 0
&& selection.isCollapsed);
}
function wikilinkQueryBeforeCursor(selection) {
var prefix = String(selection.anchorNode.textContent || "").slice(0, selection.anchorOffset);
var start = prefix.lastIndexOf("[[");
if (start < 0) return null;
var query = cleanWikilinkQuery(prefix.slice(start + 2));
if (query === null) return null;
return {
frame: wikilinkQueryFrame(selection),
query: query
};
}
function cleanWikilinkQuery(query) {
return query.indexOf("]]") >= 0 || query.indexOf("\\n") >= 0 ? null : query;
}
function emitWikilinkQuery() {
var activeQuery = activeWikilinkQuery();
postEditorMessage({
type: "wikilinkQuery",
frame: activeQuery ? activeQuery.frame : null,
query: activeQuery ? activeQuery.query : null
});
}
function wikilinkQueryFrame(selection) {
var range = selection.getRangeAt(0).cloneRange();
var rect = range.getBoundingClientRect();
if (hasVisibleFrame(rect)) {
return {
bottom: rect.bottom,
left: rect.left
};
}
return fallbackWikilinkQueryFrame(selection);
}
function fallbackWikilinkQueryFrame(selection) {
var container = selection.anchorNode && selection.anchorNode.parentElement;
var rect = container && container.getBoundingClientRect && container.getBoundingClientRect();
return {
bottom: rect ? rect.bottom : 0,
left: rect ? rect.left : 0
};
}
function hasVisibleFrame(rect) {
if (!rect) return false;
return Boolean(rect.bottom || rect.left);
}
function postEditorMessage(message) {
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
document.addEventListener("keyup", emitWikilinkQuery, true);
document.addEventListener("mouseup", emitWikilinkQuery, true);
document.addEventListener("selectionchange", emitWikilinkQuery, true);
true;
`
export const mobileEditorCss = `
* {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif !important;
}
html,
body,
#root,
.ProseMirror {
color: #292825;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
font-size: 18px;
line-height: 1.55;
}
.ProseMirror {
padding: 0;
}
.ProseMirror h1 {
font-family: inherit;
font-size: 42px;
font-weight: 760;
letter-spacing: 0;
line-height: 1.08;
margin: 18px 0 28px;
}
.ProseMirror p,
.ProseMirror li,
.ProseMirror blockquote {
font-family: inherit;
}
.ProseMirror a[href^="tolaria-note:"] {
color: #3367f6;
font-weight: 650;
text-decoration: none;
border-radius: 5px;
background: #e8eeff;
padding: 1px 4px;
}
`

View File

@@ -1,310 +0,0 @@
import { Buffer } from 'buffer'
import { createMobileVaultConfigFromMetadata } from './mobileVaultMetadata'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
import type { ExpoMobileVaultFileInfo, ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
import type { PromiseFsClient } from 'isomorphic-git'
type MobileGitStat = {
ctime: Date
ctimeMs: number
dev: number
gid: number
ino: number
isDirectory: () => boolean
isFile: () => boolean
isSymbolicLink: () => boolean
mode: number
mtime: Date
mtimeMs: number
size: number
uid: number
}
type MobileGitReadOptions = 'utf8' | { encoding?: 'base64' | 'utf8' }
type MobileGitWriteOptions = { encoding?: 'base64' | 'utf8' }
type MobileGitFileData = string | Uint8Array
type ExistingPathInput = {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}
export type MobileExpoGitFileSystemContext = {
dir: string
fs: PromiseFsClient
}
export function createMobileExpoGitFileSystem({
fileSystem,
vault,
}: {
fileSystem: ExpoMobileVaultFileSystem
vault: MobileVaultMetadata
}): MobileExpoGitFileSystemContext {
const rootUri = mobileExpoGitVaultRootUri({ fileSystem, vault })
const dir = '/vault'
return {
dir,
fs: {
promises: {
chmod: async () => {},
lstat: (path: string) => statPath({ fileSystem, path, rootUri }),
mkdir: (path: string) => mkdirPath({ fileSystem, path, rootUri }),
readFile: (path: string, options?: MobileGitReadOptions) => readFile({ fileSystem, options, path, rootUri }),
readlink: async () => {
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
},
readdir: (path: string) => readDirectory({ fileSystem, path, rootUri }),
rmdir: (path: string) => removeDirectory({ fileSystem, path, rootUri }),
stat: (path: string) => statPath({ fileSystem, path, rootUri }),
symlink: async () => {
throw createFileSystemError('ENOTSUP', 'Symbolic links are not supported in mobile vaults.')
},
unlink: (path: string) => unlinkPath({ fileSystem, path, rootUri }),
writeFile: (path: string, data: MobileGitFileData, options?: MobileGitWriteOptions) =>
writeFile({ data, fileSystem, options, path, rootUri }),
},
},
}
}
export function mobileExpoGitVaultRootUri({
fileSystem,
vault,
}: {
fileSystem: ExpoMobileVaultFileSystem
vault: MobileVaultMetadata
}) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
const vaultConfig = createMobileVaultConfigFromMetadata(vault)
return appendUri({
root: fileSystem.documentDirectory,
segments: ['vaults', vaultConfig.storage.directoryName || vaultConfig.id],
})
}
async function mkdirPath({
fileSystem,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}) {
const uri = uriForPath({ path, rootUri })
const info = await fileSystem.getInfoAsync(uri)
if (info.exists && !info.isDirectory) {
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${path}`)
}
if (!info.exists) {
await fileSystem.makeDirectoryAsync(uri, { intermediates: true })
}
}
async function readDirectory({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
await existingDirectory({ fileSystem, path, rootUri })
return fileSystem.readDirectoryAsync(uriForPath({ path, rootUri }))
}
async function readFile({
fileSystem,
options,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
options?: MobileGitReadOptions
path: string
rootUri: string
}) {
const info = await existingInfo({ fileSystem, path, rootUri })
if (info.isDirectory) {
throw createFileSystemError('EISDIR', `Path is a directory: ${path}`)
}
const uri = uriForPath({ path, rootUri })
return readAsUtf8(options)
? fileSystem.readAsStringAsync(uri, { encoding: 'utf8' })
: Buffer.from(await fileSystem.readAsStringAsync(uri, { encoding: 'base64' }), 'base64')
}
async function writeFile({
data,
fileSystem,
options,
path,
rootUri,
}: {
data: MobileGitFileData
fileSystem: ExpoMobileVaultFileSystem
options?: MobileGitWriteOptions
path: string
rootUri: string
}) {
await ensureParentDirectory({ fileSystem, path, rootUri })
if (typeof data === 'string' && writeAsUtf8(options)) {
await fileSystem.writeAsStringAsync(uriForPath({ path, rootUri }), data, { encoding: 'utf8' })
return
}
await fileSystem.writeAsStringAsync(
uriForPath({ path, rootUri }),
dataToBase64(data),
{ encoding: 'base64' },
)
}
async function unlinkPath({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
await existingFile({ fileSystem, path, rootUri })
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
}
async function removeDirectory({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
await existingDirectory({ fileSystem, path, rootUri })
await fileSystem.deleteAsync(uriForPath({ path, rootUri }))
}
async function statPath({
fileSystem,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}): Promise<MobileGitStat> {
return statForInfo(await existingInfo({ fileSystem, path, rootUri }))
}
async function existingInfo({
fileSystem,
path,
rootUri,
}: ExistingPathInput) {
const info = await fileSystem.getInfoAsync(uriForPath({ path, rootUri }))
if (!info.exists) {
throw createFileSystemError('ENOENT', `Path does not exist: ${path}`)
}
return info
}
async function existingDirectory(input: ExistingPathInput) {
const info = await existingInfo(input)
if (!info.isDirectory) {
throw createFileSystemError('ENOTDIR', `Path is not a directory: ${input.path}`)
}
}
async function existingFile(input: ExistingPathInput) {
const info = await existingInfo(input)
if (info.isDirectory) {
throw createFileSystemError('EISDIR', `Path is a directory: ${input.path}`)
}
}
function statForInfo(info: ExpoMobileVaultFileInfo): MobileGitStat {
const modifiedMs = Math.round((info.modificationTime ?? 0) * 1000)
const modified = new Date(modifiedMs)
const isDirectory = info.isDirectory === true
return {
ctime: modified,
ctimeMs: modifiedMs,
dev: 0,
gid: 0,
ino: 0,
isDirectory: () => isDirectory,
isFile: () => !isDirectory,
isSymbolicLink: () => false,
mode: isDirectory ? 0o040000 : 0o100644,
mtime: modified,
mtimeMs: modifiedMs,
size: info.size ?? 0,
uid: 0,
}
}
async function ensureParentDirectory({
fileSystem,
path,
rootUri,
}: {
fileSystem: ExpoMobileVaultFileSystem
path: string
rootUri: string
}) {
const segments = normalizedSegments(path)
const parentSegments = segments.slice(0, -1)
if (parentSegments.length === 0) {
return
}
await fileSystem.makeDirectoryAsync(appendUri({ root: rootUri, segments: parentSegments }), { intermediates: true })
}
function uriForPath({ path, rootUri }: { path: string; rootUri: string }) {
return appendUri({ root: rootUri, segments: normalizedSegments(path) })
}
function normalizedSegments(path: string) {
const pathWithoutRoot = path.replace(/^\/+vault\/?/, '')
const segments = pathWithoutRoot.replaceAll('\\', '/').split('/').filter(Boolean)
if (segments.some(isUnsafeSegment)) {
throw createFileSystemError('EINVAL', `Unsafe mobile Git path: ${path}`)
}
return segments
}
function isUnsafeSegment(segment: string) {
return segment === '.' || segment === '..' || segment.includes('/')
}
function readAsUtf8(options?: MobileGitReadOptions) {
return options === 'utf8' || (typeof options === 'object' && options.encoding === 'utf8')
}
function writeAsUtf8(options?: MobileGitWriteOptions) {
return options?.encoding === 'utf8'
}
function dataToBase64(data: MobileGitFileData) {
return typeof data === 'string'
? Buffer.from(data, 'utf8').toString('base64')
: Buffer.from(data).toString('base64')
}
function appendUri(input: { root: string; segments: string[] }) {
const base = input.root.replace(/\/+$/, '')
const path = input.segments.filter(Boolean).join('/')
return path ? `${base}/${path}` : base
}
function createFileSystemError(code: string, message: string) {
const error = new Error(message)
return Object.assign(error, { code })
}

View File

@@ -1,6 +0,0 @@
import { requireOptionalNativeModule } from 'expo-modules-core'
import { loadMobileGitNativeModule } from './mobileNativeGitModule'
export function loadExpoMobileGitNativeModule() {
return loadMobileGitNativeModule({ loadModule: requireOptionalNativeModule })
}

View File

@@ -1,124 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createExpoMobileVaultStorage, type ExpoMobileVaultFileSystem } from './mobileExpoVaultStorage'
import { createMobileVaultConfig } from './mobileVaultConfig'
const vault = createVault()
describe('Expo mobile vault storage', () => {
it('lists nested markdown files from the app-local vault directory', async () => {
const fileSystem = createFakeFileSystem({
'file:///documents/vaults/personal-journal/zeta.md': '# Zeta',
'file:///documents/vaults/personal-journal/notes/alpha.md': '# Alpha',
'file:///documents/vaults/personal-journal/asset.png': 'binary',
})
await expect(createExpoMobileVaultStorage(fileSystem).listMarkdownFiles(vault)).resolves.toEqual([
{ path: 'notes/alpha.md', content: '# Alpha' },
{ path: 'zeta.md', content: '# Zeta' },
])
})
it('creates parent directories before writing markdown files', async () => {
const fileSystem = createFakeFileSystem({})
const storage = createExpoMobileVaultStorage(fileSystem)
await storage.writeMarkdownFile(vault, 'notes/new.md', '# New')
await expect(storage.readMarkdownFile(vault, 'notes/new.md')).resolves.toBe('# New')
})
it('deletes markdown files idempotently from the app-local vault directory', async () => {
const storage = createExpoMobileVaultStorage(createFakeFileSystem({
'file:///documents/vaults/personal-journal/notes/new.md': '# New',
}))
await storage.deleteMarkdownFile(vault, 'notes/new.md')
await storage.deleteMarkdownFile(vault, 'notes/new.md')
await expect(storage.readMarkdownFile(vault, 'notes/new.md')).resolves.toBeNull()
})
it('rejects paths outside the app-local vault directory', async () => {
const storage = createExpoMobileVaultStorage(createFakeFileSystem({}))
await expect(storage.writeMarkdownFile(vault, '../outside.md', '# Nope')).rejects.toThrow(
'Unsafe mobile vault path',
)
})
})
function createVault() {
const result = createMobileVaultConfig({ id: 'personal', name: 'Personal Journal' })
if (!result.ok) {
throw new Error(result.error)
}
return result.config
}
function createFakeFileSystem(files: Record<string, string>): ExpoMobileVaultFileSystem {
const fileByUri = new Map(Object.entries(files))
const directoryUris = new Set(['file:///documents'])
for (const uri of fileByUri.keys()) {
addParentDirectories(directoryUris, uri)
}
return {
deleteAsync: async (uri) => {
fileByUri.delete(uri)
},
documentDirectory: 'file:///documents',
getInfoAsync: async (uri) => ({
exists: fileByUri.has(uri) || directoryUris.has(uri),
isDirectory: directoryUris.has(uri),
}),
makeDirectoryAsync: async (uri) => {
addDirectory(directoryUris, uri)
},
readAsStringAsync: async (uri) => {
const content = fileByUri.get(uri)
if (content === undefined) {
throw new Error(`Missing fake file: ${uri}`)
}
return content
},
readDirectoryAsync: async (uri) => listDirectoryNames({ directoryUris, fileByUri, uri }),
writeAsStringAsync: async (uri, content) => {
addParentDirectories(directoryUris, uri)
fileByUri.set(uri, content)
},
}
}
function listDirectoryNames(input: {
directoryUris: Set<string>
fileByUri: Map<string, string>
uri: string
}) {
const names = new Set<string>()
const prefix = `${input.uri.replace(/\/+$/, '')}/`
for (const uri of [...input.directoryUris, ...input.fileByUri.keys()]) {
const remaining = uri.startsWith(prefix) ? uri.slice(prefix.length) : ''
const name = remaining.split('/')[0]
if (name) {
names.add(name)
}
}
return [...names].sort()
}
function addParentDirectories(directoryUris: Set<string>, fileUri: string) {
addDirectory(directoryUris, fileUri.split('/').slice(0, -1).join('/'))
}
function addDirectory(directoryUris: Set<string>, uri: string) {
const segments = uri.split('/')
for (const index of segments.keys()) {
if (index > 1) {
directoryUris.add(segments.slice(0, index + 1).join('/'))
}
}
}

View File

@@ -1,208 +0,0 @@
import type { MobileVaultConfig } from './mobileVaultConfig'
import type { MobileVaultFile, MobileVaultStorageDriver } from './mobileVaultStorage'
export type ExpoMobileVaultFileInfo = {
exists: boolean
isDirectory?: boolean
modificationTime?: number
size?: number
}
export type ExpoMobileVaultFileSystem = {
deleteAsync: (uri: string, options?: { idempotent?: boolean }) => Promise<void>
documentDirectory: string | null
getInfoAsync: (uri: string) => Promise<ExpoMobileVaultFileInfo>
makeDirectoryAsync: (uri: string, options: { intermediates: true }) => Promise<void>
readAsStringAsync: (uri: string, options?: ExpoMobileVaultReadOptions) => Promise<string>
readDirectoryAsync: (uri: string) => Promise<string[]>
writeAsStringAsync: (uri: string, content: string, options?: ExpoMobileVaultWriteOptions) => Promise<void>
}
type VaultPathInput = {
path: string
}
export type ExpoMobileVaultReadOptions = {
encoding?: 'base64' | 'utf8'
}
export type ExpoMobileVaultWriteOptions = ExpoMobileVaultReadOptions
type DirectoryListingInput = {
fileSystem: ExpoMobileVaultFileSystem
rootUri: string
directoryPath: string
}
type DirectoryEntryInput = DirectoryListingInput & {
name: string
}
type VaultFileInput = {
fileSystem: ExpoMobileVaultFileSystem
rootUri: string
path: string
}
type DirectoryInput = {
fileSystem: ExpoMobileVaultFileSystem
uri: string
}
export function createExpoMobileVaultStorage(
fileSystem: ExpoMobileVaultFileSystem,
): MobileVaultStorageDriver {
return {
deleteMarkdownFile: (vault, path) => deleteMarkdownFile(fileSystem, vault, path),
listMarkdownFiles: (vault) => listMarkdownFiles(fileSystem, vault),
readMarkdownFile: (vault, path) => readMarkdownFile(fileSystem, vault, path),
writeMarkdownFile: (vault, path, content) => writeMarkdownFile(fileSystem, vault, path, content),
}
}
async function deleteMarkdownFile(
fileSystem: ExpoMobileVaultFileSystem,
vault: MobileVaultConfig,
path: string,
) {
const rootUri = vaultRootUri(fileSystem, vault)
const safePath = normalizeVaultPath({ path })
await fileSystem.deleteAsync(appendUri({ root: rootUri, segments: [safePath] }), { idempotent: true })
}
async function listMarkdownFiles(
fileSystem: ExpoMobileVaultFileSystem,
vault: MobileVaultConfig,
): Promise<MobileVaultFile[]> {
const rootUri = await ensureVaultRoot(fileSystem, vault)
const paths = await listMarkdownPaths({ fileSystem, rootUri, directoryPath: '' })
const files = await Promise.all(paths.map((path) => readVaultFile({ fileSystem, rootUri, path })))
return files.sort((left, right) => left.path.localeCompare(right.path))
}
async function readMarkdownFile(
fileSystem: ExpoMobileVaultFileSystem,
vault: MobileVaultConfig,
path: string,
) {
const rootUri = vaultRootUri(fileSystem, vault)
const safePath = normalizeVaultPath({ path })
const fileUri = appendUri({ root: rootUri, segments: [safePath] })
const info = await fileSystem.getInfoAsync(fileUri)
return info.exists && !info.isDirectory ? fileSystem.readAsStringAsync(fileUri) : null
}
async function writeMarkdownFile(
fileSystem: ExpoMobileVaultFileSystem,
vault: MobileVaultConfig,
path: string,
content: string,
) {
const rootUri = await ensureVaultRoot(fileSystem, vault)
const safePath = normalizeVaultPath({ path })
await ensureParentDirectory({ fileSystem, rootUri, path: safePath })
await fileSystem.writeAsStringAsync(appendUri({ root: rootUri, segments: [safePath] }), content)
}
async function listMarkdownPaths(input: DirectoryListingInput): Promise<string[]> {
const directoryUri = appendUri({ root: input.rootUri, segments: [input.directoryPath] })
const names = await input.fileSystem.readDirectoryAsync(directoryUri)
const paths = await Promise.all(
names.map((name) => listDirectoryEntry({ ...input, name })),
)
return paths.flat()
}
async function listDirectoryEntry(input: DirectoryEntryInput): Promise<string[]> {
if (isUnsafeSegment(input.name)) {
return []
}
const path = input.directoryPath ? `${input.directoryPath}/${input.name}` : input.name
const info = await input.fileSystem.getInfoAsync(appendUri({ root: input.rootUri, segments: [path] }))
if (!info.exists) {
return []
}
if (info.isDirectory) {
return listMarkdownPaths({ fileSystem: input.fileSystem, rootUri: input.rootUri, directoryPath: path })
}
return path.endsWith('.md') ? [path] : []
}
async function readVaultFile(input: VaultFileInput): Promise<MobileVaultFile> {
return {
path: input.path,
content: await input.fileSystem.readAsStringAsync(
appendUri({ root: input.rootUri, segments: [input.path] }),
),
}
}
async function ensureVaultRoot(fileSystem: ExpoMobileVaultFileSystem, vault: MobileVaultConfig) {
const rootUri = vaultRootUri(fileSystem, vault)
await ensureDirectory({ fileSystem, uri: rootUri })
return rootUri
}
async function ensureParentDirectory(input: VaultFileInput) {
const parentPath = input.path.split('/').slice(0, -1).join('/')
if (parentPath) {
await ensureDirectory({
fileSystem: input.fileSystem,
uri: appendUri({ root: input.rootUri, segments: [parentPath] }),
})
}
}
async function ensureDirectory(input: DirectoryInput) {
const info = await input.fileSystem.getInfoAsync(input.uri)
if (info.exists && !info.isDirectory) {
throw new Error(`Mobile vault path is not a directory: ${input.uri}`)
}
if (!info.exists) {
await input.fileSystem.makeDirectoryAsync(input.uri, { intermediates: true })
}
}
function vaultRootUri(fileSystem: ExpoMobileVaultFileSystem, vault: MobileVaultConfig) {
if (!fileSystem.documentDirectory) {
throw new Error('Expo FileSystem documentDirectory is unavailable')
}
return appendUri({
root: fileSystem.documentDirectory,
segments: ['vaults', vault.storage.directoryName || vault.id],
})
}
function normalizeVaultPath(input: VaultPathInput) {
const path = input.path
const segments = path.replaceAll('\\', '/').split('/').filter(Boolean)
if (isUnsafeVaultPath({ path, segments })) {
throw new Error(`Unsafe mobile vault path: ${path}`)
}
return segments.join('/')
}
function isUnsafeVaultPath(input: VaultPathInput & { segments: string[] }) {
return !input.path.endsWith('.md') || input.segments.length === 0 || input.segments.some(isUnsafeSegment)
}
function isUnsafeSegment(segment: string) {
return segment === '.' || segment === '..' || segment.includes('/')
}
function appendUri(input: { root: string; segments: string[] }) {
const base = input.root.replace(/\/+$/, '')
const path = input.segments.filter(Boolean).join('/')
return path ? `${base}/${path}` : base
}

View File

@@ -1,87 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication'
describe('authenticateMobileGitSyncPlan', () => {
it('ignores plans that do not need authentication', async () => {
await expect(authenticateMobileGitSyncPlan({
credentialStorage: noopCredentialStorage(),
createGitHubOAuthSession: failingSession,
now: () => '2026-05-05T12:00:00.000Z',
plan: { primaryAction: null, state: 'localOnly' },
})).resolves.toEqual({ state: 'ignored' })
})
it('connects GitHub auth-required plans through the OAuth session', async () => {
const credentialStorage = memoryCredentialStorage()
await expect(authenticateMobileGitSyncPlan({
credentialStorage,
createGitHubOAuthSession: () => ({
authorize: async () => ({
state: 'succeeded',
token: { accessToken: 'token', tokenType: 'bearer' },
}),
}),
now: () => '2026-05-05T12:00:00.000Z',
plan: {
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
state: 'authRequired',
},
})).resolves.toEqual({ state: 'connected' })
await expect(credentialStorage.loadState({ host: 'github.com', strategy: 'githubOAuth' }))
.resolves.toEqual({ state: 'available' })
})
it('fails unsupported SSH authentication without starting OAuth', async () => {
await expect(authenticateMobileGitSyncPlan({
credentialStorage: noopCredentialStorage(),
createGitHubOAuthSession: failingSession,
now: () => '2026-05-05T12:00:00.000Z',
plan: {
authStrategy: 'sshKey',
host: 'git.example.com',
primaryAction: 'authenticate',
state: 'authRequired',
},
})).resolves.toEqual({
message: 'SSH credential setup is not available yet.',
state: 'failed',
})
})
})
function memoryCredentialStorage(): MobileGitCredentialStorage {
let isAvailable = false
return {
loadRecord: async () => null,
loadState: async () => isAvailable ? { state: 'available' } : { state: 'missing' },
remove: async () => {
isAvailable = false
},
saveRecord: async () => {
isAvailable = true
},
}
}
function noopCredentialStorage(): MobileGitCredentialStorage {
return {
loadRecord: async () => null,
loadState: async () => ({ state: 'missing' }),
remove: async () => {},
saveRecord: async () => {},
}
}
function failingSession() {
return {
authorize: async () => {
throw new Error('should not start OAuth')
},
}
}

View File

@@ -1,55 +0,0 @@
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { MobileGitSyncPlan } from './mobileGitSyncPlan'
import { connectMobileGitHubOAuth, type MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
export type MobileGitAuthenticationResult =
| {
state: 'connected'
}
| {
state: 'cancelled'
}
| {
message: string
state: 'failed'
}
| {
state: 'ignored'
}
export async function authenticateMobileGitSyncPlan({
credentialStorage,
createGitHubOAuthSession,
now,
plan,
}: {
credentialStorage: MobileGitCredentialStorage
createGitHubOAuthSession: () => MobileGitHubOAuthSession
now: () => string
plan: MobileGitSyncPlan
}): Promise<MobileGitAuthenticationResult> {
if (!canAuthenticate(plan)) {
return { state: 'ignored' }
}
if (authStrategy(plan) !== 'githubOAuth') {
return {
message: 'SSH credential setup is not available yet.',
state: 'failed',
}
}
return connectMobileGitHubOAuth({
credentialStorage,
now,
session: createGitHubOAuthSession(),
})
}
function canAuthenticate(plan: MobileGitSyncPlan) {
return plan.state === 'authRequired' || plan.state === 'failed'
}
function authStrategy(plan: Extract<MobileGitSyncPlan, { state: 'authRequired' | 'failed' }>) {
return plan.state === 'authRequired' ? plan.authStrategy : plan.remote.authStrategy
}

View File

@@ -1,62 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
createMobileGitCredentialRecord,
type MobileGitCredentialStorage,
} from './mobileGitCredentialStorage'
import { loadMobileGitCredentialStateForVault } from './mobileGitCredentialStateForVault'
describe('loadMobileGitCredentialStateForVault', () => {
it('keeps local-only vaults credential-missing without hitting secure storage', async () => {
await expect(loadMobileGitCredentialStateForVault({
credentialStorage: failingCredentialStorage(),
vault: { id: 'personal', name: 'Personal Journal' },
})).resolves.toEqual({ state: 'missing' })
})
it('loads credential state for a remote-backed vault auth requirement', async () => {
const credentialStorage = memoryCredentialStorage()
await credentialStorage.saveRecord(createMobileGitCredentialRecord({
requirement: { host: 'github.com', strategy: 'githubOAuth' },
storedAt: '2026-05-05T12:00:00.000Z',
}))
await expect(loadMobileGitCredentialStateForVault({
credentialStorage,
vault: {
id: 'tolaria',
name: 'Tolaria',
remoteUrl: 'https://github.com/refactoringhq/tolaria.git',
},
})).resolves.toEqual({ state: 'available' })
})
})
function memoryCredentialStorage(): MobileGitCredentialStorage {
const records = new Map<string, ReturnType<typeof createMobileGitCredentialRecord>>()
return {
loadRecord: async (requirement) => records.get(`${requirement.strategy}:${requirement.host}`) ?? null,
loadState: async (requirement) => records.has(`${requirement.strategy}:${requirement.host}`)
? { state: 'available' }
: { state: 'missing' },
remove: async (requirement) => {
records.delete(`${requirement.strategy}:${requirement.host}`)
},
saveRecord: async (record) => {
records.set(`${record.strategy}:${record.host}`, record)
},
}
}
function failingCredentialStorage(): MobileGitCredentialStorage {
return {
loadRecord: async () => {
throw new Error('should not load credentials')
},
loadState: async () => {
throw new Error('should not load credentials')
},
remove: async () => {},
saveRecord: async () => {},
}
}

View File

@@ -1,19 +0,0 @@
import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage'
import type { MobileGitCredentialState } from './mobileGitSyncPlan'
import { createMobileVaultConfig } from './mobileVaultConfig'
import type { MobileVaultMetadata } from './mobileVaultMetadata'
export async function loadMobileGitCredentialStateForVault({
credentialStorage,
vault,
}: {
credentialStorage: MobileGitCredentialStorage
vault: MobileVaultMetadata
}): Promise<MobileGitCredentialState> {
const result = createMobileVaultConfig(vault)
if (!result.ok || result.config.sync.state === 'localOnly') {
return { state: 'missing' }
}
return credentialStorage.loadState(result.config.sync.authRequirement)
}

View File

@@ -1,73 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
createMobileGitCredentialKey,
createMobileGitCredentialRecord,
mobileGitCredentialState,
parseMobileGitCredentialRecord,
serializeMobileGitCredentialRecord,
} from './mobileGitCredentialStorage'
import type { MobileVaultAuthRequirement } from './mobileVaultConfig'
describe('mobile Git credential storage model', () => {
it('creates stable host-normalized secure storage keys', () => {
expect(createMobileGitCredentialKey(githubRequirement(' GitHub.COM '))).toBe(
'tolaria:git-credential:githubOAuth:github.com',
)
})
it('creates a credential presence record for the required auth strategy', () => {
expect(createMobileGitCredentialRecord({
requirement: githubRequirement('github.com'),
storedAt: '2026-05-05T11:00:00.000Z',
})).toEqual({
host: 'github.com',
kind: 'githubOAuthToken',
strategy: 'githubOAuth',
storedAt: '2026-05-05T11:00:00.000Z',
})
expect(createMobileGitCredentialRecord({
requirement: sshRequirement('git.example.com'),
storedAt: '2026-05-05T11:00:00.000Z',
})).toMatchObject({
kind: 'sshKeyReference',
strategy: 'sshKey',
})
})
it('loads available state only when the record matches the required host and strategy', () => {
const record = createMobileGitCredentialRecord({
requirement: githubRequirement('github.com'),
storedAt: '2026-05-05T11:00:00.000Z',
})
expect(mobileGitCredentialState({
record,
requirement: githubRequirement('github.com'),
})).toEqual({ state: 'available' })
expect(mobileGitCredentialState({
record,
requirement: sshRequirement('github.com'),
})).toEqual({ state: 'missing' })
})
it('parses stored records defensively', () => {
const record = createMobileGitCredentialRecord({
requirement: githubRequirement('github.com'),
storedAt: '2026-05-05T11:00:00.000Z',
})
expect(parseMobileGitCredentialRecord(serializeMobileGitCredentialRecord(record))).toEqual(record)
expect(parseMobileGitCredentialRecord('{')).toBeNull()
expect(parseMobileGitCredentialRecord(JSON.stringify({ host: 'github.com' }))).toBeNull()
})
})
function githubRequirement(host: string): MobileVaultAuthRequirement {
return { host, strategy: 'githubOAuth' }
}
function sshRequirement(host: string): MobileVaultAuthRequirement {
return { host, strategy: 'sshKey' }
}

View File

@@ -1,148 +0,0 @@
import type { MobileGitAuthStrategy } from './mobileGitRemote'
import type { MobileGitCredentialState } from './mobileGitSyncPlan'
import type { MobileVaultAuthRequirement } from './mobileVaultConfig'
export type MobileGitCredentialKind = 'githubOAuthToken' | 'sshKeyReference'
export type MobileGitCredentialSecret = {
accessToken: string
scope?: string
tokenType: string
}
export type MobileGitCredentialRecord = {
host: string
kind: MobileGitCredentialKind
secret?: MobileGitCredentialSecret
strategy: MobileGitAuthStrategy
storedAt: string
}
export type MobileGitCredentialStorage = {
loadRecord: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialRecord | null>
loadState: (requirement: MobileVaultAuthRequirement) => Promise<MobileGitCredentialState>
remove: (requirement: MobileVaultAuthRequirement) => Promise<void>
saveRecord: (record: MobileGitCredentialRecord) => Promise<void>
}
export function createMobileGitCredentialRecord({
requirement,
secret,
storedAt,
}: {
requirement: MobileVaultAuthRequirement
secret?: MobileGitCredentialSecret
storedAt: string
}): MobileGitCredentialRecord {
return {
host: normalizeCredentialHost(requirement.host),
kind: credentialKind(requirement.strategy),
...(secret ? { secret } : {}),
strategy: requirement.strategy,
storedAt,
}
}
export function createMobileGitCredentialKey(requirement: MobileVaultAuthRequirement) {
return [
'tolaria',
'git-credential',
requirement.strategy,
normalizeCredentialHost(requirement.host),
].join(':')
}
export function parseMobileGitCredentialRecord(content: string | null): MobileGitCredentialRecord | null {
if (!content) {
return null
}
try {
return normalizeMobileGitCredentialRecord(JSON.parse(content))
} catch {
return null
}
}
export function mobileGitCredentialState({
record,
requirement,
}: {
record: MobileGitCredentialRecord | null
requirement: MobileVaultAuthRequirement
}): MobileGitCredentialState {
return record && matchesRequirement({ record, requirement })
? { state: 'available' }
: { state: 'missing' }
}
export function serializeMobileGitCredentialRecord(record: MobileGitCredentialRecord) {
return JSON.stringify(record)
}
function normalizeMobileGitCredentialRecord(value: unknown): MobileGitCredentialRecord | null {
if (!isCredentialRecord(value)) {
return null
}
return {
host: normalizeCredentialHost(value.host),
kind: value.kind,
...(isCredentialSecret(value.secret) ? { secret: value.secret } : {}),
strategy: value.strategy,
storedAt: value.storedAt,
}
}
function matchesRequirement({
record,
requirement,
}: {
record: MobileGitCredentialRecord
requirement: MobileVaultAuthRequirement
}) {
return record.strategy === requirement.strategy
&& record.host === normalizeCredentialHost(requirement.host)
&& record.kind === credentialKind(requirement.strategy)
}
function credentialKind(strategy: MobileGitAuthStrategy): MobileGitCredentialKind {
return strategy === 'githubOAuth' ? 'githubOAuthToken' : 'sshKeyReference'
}
function normalizeCredentialHost(host: string) {
return host.trim().toLowerCase()
}
function isCredentialRecord(value: unknown): value is MobileGitCredentialRecord {
return typeof value === 'object'
&& value !== null
&& hasText((value as MobileGitCredentialRecord).host)
&& isCredentialKind((value as MobileGitCredentialRecord).kind)
&& isCredentialStrategy((value as MobileGitCredentialRecord).strategy)
&& hasText((value as MobileGitCredentialRecord).storedAt)
}
function hasText(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function isCredentialKind(value: unknown): value is MobileGitCredentialKind {
return value === 'githubOAuthToken' || value === 'sshKeyReference'
}
function isCredentialStrategy(value: unknown): value is MobileGitAuthStrategy {
return value === 'githubOAuth' || value === 'sshKey'
}
function isCredentialSecret(value: unknown): value is MobileGitCredentialSecret {
return typeof value === 'object'
&& value !== null
&& hasText((value as MobileGitCredentialSecret).accessToken)
&& hasText((value as MobileGitCredentialSecret).tokenType)
&& isOptionalText((value as MobileGitCredentialSecret).scope)
}
function isOptionalText(value: unknown): value is string | undefined {
return value === undefined || hasText(value)
}

View File

@@ -1,62 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
createMobileGitHubOAuthRequest,
normalizeMobileGitHubAuthorizationResult,
} from './mobileGitHubOAuth'
describe('createMobileGitHubOAuthRequest', () => {
it('builds a PKCE authorization-code request for GitHub OAuth', () => {
expect(createMobileGitHubOAuthRequest({
clientId: ' abc123 ',
redirectUri: ' tolaria://oauth/github ',
})).toEqual({
ok: true,
request: {
clientId: 'abc123',
extraParams: { allow_signup: 'true' },
redirectUri: 'tolaria://oauth/github',
responseType: 'code',
scopes: ['repo'],
usePKCE: true,
},
})
})
it.each([
{ clientId: '', error: 'missingClientId', redirectUri: 'tolaria://oauth/github' },
{ clientId: 'abc123', error: 'missingRedirectUri', redirectUri: '' },
] as const)('rejects $error', ({ clientId, error, redirectUri }) => {
expect(createMobileGitHubOAuthRequest({ clientId, redirectUri })).toEqual({
error,
ok: false,
})
})
})
describe('normalizeMobileGitHubAuthorizationResult', () => {
it('extracts the temporary authorization code', () => {
expect(normalizeMobileGitHubAuthorizationResult({
params: { code: 'temporary-code' },
type: 'success',
})).toEqual({
code: 'temporary-code',
state: 'authorized',
})
})
it('keeps user cancellation separate from auth failure', () => {
expect(normalizeMobileGitHubAuthorizationResult({ type: 'cancel' })).toEqual({
state: 'cancelled',
})
})
it('normalizes provider errors without exposing raw result objects', () => {
expect(normalizeMobileGitHubAuthorizationResult({
params: { error: 'access_denied' },
type: 'error',
})).toEqual({
message: 'access_denied',
state: 'failed',
})
})
})

View File

@@ -1,98 +0,0 @@
export const mobileGitHubOAuthDiscovery = {
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
tokenEndpoint: 'https://github.com/login/oauth/access_token',
}
export const defaultMobileGitHubOAuthScopes = ['repo']
export type MobileGitHubOAuthRequestConfig = {
clientId: string
extraParams: Record<string, string>
redirectUri: string
responseType: 'code'
scopes: string[]
usePKCE: true
}
export type CreateMobileGitHubOAuthRequestResult =
| {
ok: true
request: MobileGitHubOAuthRequestConfig
}
| {
ok: false
error: 'missingClientId' | 'missingRedirectUri'
}
export type MobileGitHubAuthorizationResult =
| {
code: string
state: 'authorized'
}
| {
state: 'cancelled'
}
| {
message: string
state: 'failed'
}
export function createMobileGitHubOAuthRequest({
clientId,
redirectUri,
scopes = defaultMobileGitHubOAuthScopes,
}: {
clientId: string
redirectUri: string
scopes?: string[]
}): CreateMobileGitHubOAuthRequestResult {
if (!clientId.trim()) {
return { error: 'missingClientId', ok: false }
}
if (!redirectUri.trim()) {
return { error: 'missingRedirectUri', ok: false }
}
return {
ok: true,
request: {
clientId: clientId.trim(),
extraParams: { allow_signup: 'true' },
redirectUri: redirectUri.trim(),
responseType: 'code',
scopes,
usePKCE: true,
},
}
}
export function normalizeMobileGitHubAuthorizationResult(result: {
error?: { message?: string } | null
params?: Record<string, string>
type: string
}): MobileGitHubAuthorizationResult {
if (result.type === 'success' && result.params?.code) {
return { code: result.params.code, state: 'authorized' }
}
if (['cancel', 'dismiss'].includes(result.type)) {
return { state: 'cancelled' }
}
return {
message: oauthFailureMessage(result),
state: 'failed',
}
}
function oauthFailureMessage(result: {
error?: { message?: string } | null
params?: Record<string, string>
type: string
}) {
return result.error?.message
?? result.params?.error_description
?? result.params?.error
?? `GitHub OAuth ended with ${result.type}.`
}

View File

@@ -1,15 +0,0 @@
export type MobileGitHubOAuthClientIdState =
| {
clientId: string
state: 'configured'
}
| {
state: 'missing'
}
export function mobileGitHubOAuthClientIdState(
env: Record<string, string | undefined> | undefined,
): MobileGitHubOAuthClientIdState {
const clientId = env?.EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID?.trim()
return clientId ? { clientId, state: 'configured' } : { state: 'missing' }
}

View File

@@ -1,20 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mobileGitHubOAuthClientIdState } from './mobileGitHubOAuthClientId'
describe('mobileGitHubOAuthClientIdState', () => {
it('detects a configured Expo public GitHub OAuth client id', () => {
expect(mobileGitHubOAuthClientIdState({
EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID: ' abc123 ',
})).toEqual({
clientId: 'abc123',
state: 'configured',
})
})
it('reports a missing client id when the env value is absent or blank', () => {
expect(mobileGitHubOAuthClientIdState({})).toEqual({ state: 'missing' })
expect(mobileGitHubOAuthClientIdState({
EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID: ' ',
})).toEqual({ state: 'missing' })
})
})

View File

@@ -1,26 +0,0 @@
import { createNativeMobileGitHubOAuthSession } from './mobileNativeGitHubOAuthSession'
import {
mobileGitHubOAuthClientIdState,
type MobileGitHubOAuthClientIdState,
} from './mobileGitHubOAuthClientId'
import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
declare const process: { env?: Record<string, string | undefined> } | undefined
export function createNativeMobileGitHubOAuthSessionFromEnvironment(): MobileGitHubOAuthSession {
const clientIdState = currentMobileGitHubOAuthClientIdState()
if (clientIdState.state === 'missing') {
return {
authorize: async () => ({
message: 'GitHub OAuth client ID is not configured.',
state: 'failed',
}),
}
}
return createNativeMobileGitHubOAuthSession({ clientId: clientIdState.clientId })
}
export function currentMobileGitHubOAuthClientIdState(): MobileGitHubOAuthClientIdState {
return mobileGitHubOAuthClientIdState(process?.env)
}

View File

@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
connectMobileGitHubOAuth,
type MobileGitHubOAuthSession,
} from './mobileGitHubOAuthFlow'
import { createMobileSecureGitCredentialStorage } from './mobileSecureGitCredentialStorage'
import type { MobileSecureStore } from './mobileSecureGitCredentialStorage'
describe('connectMobileGitHubOAuth', () => {
it('stores a GitHub credential record after successful OAuth', async () => {
const secureStore = createMemorySecureStore()
const storage = createMobileSecureGitCredentialStorage(secureStore)
await expect(connectMobileGitHubOAuth({
credentialStorage: storage,
now: () => '2026-05-05T12:00:00.000Z',
session: successfulSession(),
})).resolves.toEqual({ state: 'connected' })
await expect(secureStore.getItemAsync('tolaria:git-credential:githubOAuth:github.com'))
.resolves.toContain('"accessToken":"github-token"')
await expect(storage.loadState({ host: 'github.com', strategy: 'githubOAuth' }))
.resolves.toEqual({ state: 'available' })
})
it('does not store credentials when the user cancels', async () => {
const secureStore = createMemorySecureStore()
await expect(connectMobileGitHubOAuth({
credentialStorage: createMobileSecureGitCredentialStorage(secureStore),
now: () => '2026-05-05T12:00:00.000Z',
session: { authorize: async () => ({ state: 'cancelled' }) },
})).resolves.toEqual({ state: 'cancelled' })
await expect(secureStore.getItemAsync('tolaria:git-credential:githubOAuth:github.com'))
.resolves.toBeNull()
})
})
function successfulSession(): MobileGitHubOAuthSession {
return {
authorize: async () => ({
state: 'succeeded',
token: {
accessToken: 'github-token',
scope: 'repo',
tokenType: 'bearer',
},
}),
}
}
function createMemorySecureStore(): MobileSecureStore {
const values = new Map<string, string>()
return {
deleteItemAsync: async (key) => {
values.delete(key)
},
getItemAsync: async (key) => values.get(key) ?? null,
setItemAsync: async (key, value) => {
values.set(key, value)
},
}
}

View File

@@ -1,68 +0,0 @@
import {
createMobileGitCredentialRecord,
type MobileGitCredentialStorage,
} from './mobileGitCredentialStorage'
import type { MobileVaultAuthRequirement } from './mobileVaultConfig'
export type MobileGitHubOAuthToken = {
accessToken: string
scope?: string
tokenType: string
}
export type MobileGitHubOAuthSessionResult =
| {
state: 'succeeded'
token: MobileGitHubOAuthToken
}
| {
state: 'cancelled'
}
| {
message: string
state: 'failed'
}
export type MobileGitHubOAuthSession = {
authorize: () => Promise<MobileGitHubOAuthSessionResult>
}
export type ConnectMobileGitHubOAuthResult =
| {
state: 'connected'
}
| {
state: 'cancelled'
}
| {
message: string
state: 'failed'
}
export async function connectMobileGitHubOAuth({
credentialStorage,
now,
session,
}: {
credentialStorage: MobileGitCredentialStorage
now: () => string
session: MobileGitHubOAuthSession
}): Promise<ConnectMobileGitHubOAuthResult> {
const result = await session.authorize()
if (result.state !== 'succeeded') {
return result
}
await credentialStorage.saveRecord(createMobileGitCredentialRecord({
requirement: githubRequirement(),
secret: result.token,
storedAt: now(),
}))
return { state: 'connected' }
}
export function githubRequirement(): MobileVaultAuthRequirement {
return { host: 'github.com', strategy: 'githubOAuth' }
}

View File

@@ -1,70 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mobileGitPrimaryActionForPlan } from './mobileGitPrimaryAction'
import type { MobileGitRemote } from './mobileGitRemote'
describe('mobile git primary action', () => {
it('routes missing credentials to authentication', () => {
expect(mobileGitPrimaryActionForPlan({
authStrategy: 'githubOAuth',
host: 'github.com',
primaryAction: 'authenticate',
state: 'authRequired',
})).toEqual({ state: 'authenticate' })
})
it('routes ready pull and push states to transport execution', () => {
expect(mobileGitPrimaryActionForPlan({
canPull: true,
canPush: false,
primaryAction: 'pull',
remote: remote(),
state: 'ready',
})).toMatchObject({ operation: 'pull', state: 'transport' })
expect(mobileGitPrimaryActionForPlan({
canPull: true,
canPush: true,
primaryAction: 'push',
remote: remote(),
state: 'ready',
})).toMatchObject({ operation: 'push', state: 'transport' })
})
it('retries clone failures through authentication and sync failures through transport', () => {
expect(mobileGitPrimaryActionForPlan({
message: 'GitHub authentication failed.',
operation: 'clone',
primaryAction: 'retry',
remote: remote(),
state: 'failed',
})).toEqual({ state: 'authenticate' })
expect(mobileGitPrimaryActionForPlan({
message: 'Pull failed.',
operation: 'pull',
primaryAction: 'retry',
remote: remote(),
state: 'failed',
})).toMatchObject({ operation: 'pull', state: 'transport' })
})
it('ignores local-only and already syncing plans', () => {
expect(mobileGitPrimaryActionForPlan({ primaryAction: null, state: 'localOnly' })).toEqual({ state: 'ignored' })
expect(mobileGitPrimaryActionForPlan({
operation: 'pull',
primaryAction: null,
remote: remote(),
state: 'syncing',
})).toEqual({ state: 'ignored' })
})
})
function remote(): MobileGitRemote {
return {
authStrategy: 'githubOAuth',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
url: 'https://github.com/refactoringhq/tolaria.git',
}
}

View File

@@ -1,52 +0,0 @@
import type { MobileGitOperation, MobileGitSyncPlan } from './mobileGitSyncPlan'
import type { MobileGitRemote } from './mobileGitRemote'
export type MobileGitTransportOperation = Exclude<MobileGitOperation, 'clone'>
export type MobileGitPrimaryAction =
| {
state: 'authenticate'
}
| {
operation: MobileGitTransportOperation
remote: MobileGitRemote
state: 'transport'
}
| {
state: 'ignored'
}
export function mobileGitPrimaryActionForPlan(plan: MobileGitSyncPlan): MobileGitPrimaryAction {
if (plan.state === 'authRequired') {
return { state: 'authenticate' }
}
if (plan.state === 'ready') {
return transportAction({
operation: plan.primaryAction,
remote: plan.remote,
})
}
if (plan.state === 'failed') {
return plan.operation === 'clone'
? { state: 'authenticate' }
: transportAction({ operation: plan.operation, remote: plan.remote })
}
return { state: 'ignored' }
}
function transportAction({
operation,
remote,
}: {
operation: MobileGitTransportOperation
remote: MobileGitRemote
}): MobileGitPrimaryAction {
return {
operation,
remote,
state: 'transport',
}
}

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest'
import { parseMobileGitRemote } from './mobileGitRemote'
describe('mobile git remote parsing', () => {
it('uses GitHub OAuth for GitHub HTTPS remotes', () => {
expect(parseMobileGitRemote('https://github.com/refactoringhq/tolaria.git')).toEqual({
url: 'https://github.com/refactoringhq/tolaria.git',
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
authStrategy: 'githubOAuth',
})
})
it('uses GitHub OAuth for GitHub SSH shorthand remotes', () => {
expect(parseMobileGitRemote('git@github.com:refactoringhq/tolaria.git')).toMatchObject({
host: 'github.com',
owner: 'refactoringhq',
repository: 'tolaria',
authStrategy: 'githubOAuth',
})
})
it('uses SSH keys for non-GitHub SSH remotes', () => {
expect(parseMobileGitRemote('ssh://git@git.example.com/acme/notes.git')).toMatchObject({
host: 'git.example.com',
owner: 'acme',
repository: 'notes',
authStrategy: 'sshKey',
})
})
it('uses SSH keys for non-GitHub HTTPS remotes', () => {
expect(parseMobileGitRemote('https://gitlab.com/acme/notes.git')).toMatchObject({
host: 'gitlab.com',
owner: 'acme',
repository: 'notes',
authStrategy: 'sshKey',
})
})
it('rejects non-remote input', () => {
expect(parseMobileGitRemote('/Users/luca/Laputa')).toBeNull()
expect(parseMobileGitRemote('')).toBeNull()
})
})

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