Compare commits

...

168 Commits

Author SHA1 Message Date
lucaronin
5eeb37e32d fix: make pulse rows edge to edge 2026-04-16 16:18:45 +02:00
lucaronin
193b7c2a44 fix: align sidebar count column 2026-04-16 15:45:28 +02:00
lucaronin
a36760efe6 fix: satisfy rust clippy gate 2026-04-16 15:09:24 +02:00
lucaronin
edd5a1ce8f feat: align release version naming 2026-04-16 14:43:08 +02:00
lucaronin
991ee8689e refactor: simplify note item composition 2026-04-16 13:58:17 +02:00
lucaronin
e111700203 fix: restore relationship panel layout 2026-04-16 13:04:35 +02:00
lucaronin
2772064b2a fix: prefer local mtimes in modified sort 2026-04-16 12:44:12 +02:00
lucaronin
c0d16e4b4d feat: add h1 auto-rename setting 2026-04-16 12:18:11 +02:00
lucaronin
711d49b3bb fix: align property panel rows 2026-04-16 11:53:29 +02:00
lucaronin
53b3f5a14b fix: type editor formatting block selector 2026-04-16 11:28:50 +02:00
lucaronin
d1dbada9e4 fix: restore markdown-safe editor formatting controls 2026-04-16 11:24:37 +02:00
lucaronin
4e66f09eef fix: make note-list property pickers searchable 2026-04-16 10:25:56 +02:00
lucaronin
f5b1d763c1 docs: ratchet codescene thresholds 2026-04-16 09:51:59 +02:00
lucaronin
905754e0c5 fix: reduce editor-only window width floor 2026-04-16 09:38:34 +02:00
lucaronin
f665f089dc fix: narrow note list entity selection 2026-04-16 09:11:22 +02:00
lucaronin
57a87e43b3 fix: make keyboard note navigation predictable 2026-04-16 09:04:13 +02:00
lucaronin
1b3a9e3ecf fix: keep raw frontmatter out of markdown headings 2026-04-16 07:49:31 +02:00
lucaronin
f8bd3e1b4a feat: show property kind icons 2026-04-16 07:27:11 +02:00
lucaronin
1b10aa9660 test: cover mcp config edge cases 2026-04-16 07:03:08 +02:00
lucaronin
514c1f9ef7 fix: satisfy rust quality gates 2026-04-16 06:42:58 +02:00
lucaronin
e9c1ae2b8a fix: canonicalize hidden system metadata 2026-04-16 06:21:34 +02:00
lucaronin
e8e2004aa8 fix: make the pulse header draggable 2026-04-16 05:41:41 +02:00
lucaronin
a448945ed5 fix: format window constraint command imports 2026-04-16 05:07:33 +02:00
lucaronin
d469f220e7 fix: make main window min width follow visible panes 2026-04-16 05:03:01 +02:00
lucaronin
d60c47ee27 feat: standardize relationship property labels 2026-04-16 03:45:34 +02:00
lucaronin
087e9f13c8 fix: rename getting started starter repo 2026-04-16 03:08:29 +02:00
lucaronin
ed93b4b234 fix: stop defaulting new note status 2026-04-16 02:42:43 +02:00
lucaronin
b8dfc79e80 fix: align note list model event types 2026-04-16 02:21:55 +02:00
lucaronin
bbf98d6112 feat: customize all-notes note list columns 2026-04-16 02:18:43 +02:00
lucaronin
f3ee593dee test: include multi-selection smoke in curated suite 2026-04-16 01:46:24 +02:00
lucaronin
2bd5d64497 fix: include multi-selection keyboard action type 2026-04-16 01:39:35 +02:00
lucaronin
9c0bda55f6 fix: apply note shortcuts to full multi-selection 2026-04-16 01:36:44 +02:00
lucaronin
2c0c464eac chore: ratchet codescene thresholds 2026-04-16 00:45:17 +02:00
lucaronin
82192f23be feat: simplify bulk action bar 2026-04-16 00:38:43 +02:00
lucaronin
310e159894 fix: remove status bar note count 2026-04-16 00:01:26 +02:00
lucaronin
77a7d9d519 fix: preserve editor mode position 2026-04-15 23:33:30 +02:00
lucaronin
37f099ab1e fix: keep editor formatting markdown-safe 2026-04-15 22:42:40 +02:00
lucaronin
8f72c4839d fix: preserve zoomed block handle hover 2026-04-15 19:42:25 +02:00
lucaronin
a1331453ad fix: keep untitled rename non-blocking 2026-04-15 18:59:51 +02:00
lucaronin
8518e89f03 fix: prevent duplicate untitled saves during rename 2026-04-15 18:33:44 +02:00
lucaronin
a5cde9e2d6 fix: open pulse rows in commit diff mode 2026-04-15 17:57:38 +02:00
lucaronin
2ad3d1cae1 fix: inject telemetry envs into release builds 2026-04-15 16:59:31 +02:00
lucaronin
7eaf57d040 fix: make getting started clone destination explicit 2026-04-15 14:55:56 +02:00
lucaronin
e142b4c8f9 docs: add/update ADR for root AI guidance files (guard — from commit ec3f90e) 2026-04-15 10:23:24 +02:00
lucaronin
8f04ed59a4 fix: remap buffered saves after untitled rename 2026-04-14 23:48:30 +02:00
lucaronin
8a5763959b fix: keep untitled rename non-blocking 2026-04-14 21:15:01 +02:00
lucaronin
4ab44fbe1a fix: preserve untitled rename cursor 2026-04-14 21:10:51 +02:00
lucaronin
2720c14e22 fix: refresh stale vault agent guidance 2026-04-14 20:45:52 +02:00
lucaronin
8d3bfc2ca7 chore: ratchet codescene thresholds 2026-04-14 20:12:56 +02:00
lucaronin
9ccd0a7221 fix: remove all notes status pills 2026-04-14 20:07:54 +02:00
lucaronin
1388e7ddbe fix: align sidebar view count chips 2026-04-14 19:45:32 +02:00
lucaronin
cd76c0d65d feat: add searchable type combobox 2026-04-14 18:48:56 +02:00
lucaronin
d9fd615d4c fix: open note windows from direct note paths 2026-04-14 18:17:45 +02:00
lucaronin
1f4ae56c1c fix: preserve aliased link labels 2026-04-14 17:34:22 +02:00
lucaronin
084d3d9c71 fix: preserve exact linked note titles 2026-04-14 17:32:47 +02:00
lucaronin
9041246a32 test: stabilize CI vault defaults 2026-04-14 17:04:23 +02:00
lucaronin
6a8fe11027 fix: flush title rename saves before path changes 2026-04-14 16:49:18 +02:00
lucaronin
0078cc257a fix: harden rename saves and async deletes 2026-04-14 15:40:46 +02:00
lucaronin
9f46f22667 fix: skip alpha dmg packaging on CI 2026-04-14 15:39:33 +02:00
lucaronin
2b734da4cb fix: clear cached tauri bundle artifacts 2026-04-14 15:30:10 +02:00
lucaronin
73c619892b fix: rotate tauri target cache keys 2026-04-14 15:18:05 +02:00
lucaronin
8f1b3c75b2 feat: show property type icons in rows 2026-04-14 15:14:46 +02:00
lucaronin
39da825443 fix: standardize property chip sizing 2026-04-14 15:01:59 +02:00
lucaronin
7b86e3a524 fix: align properties panel value columns 2026-04-14 14:40:31 +02:00
lucaronin
ab92ea178f fix: align vault guidance status typing 2026-04-14 14:20:30 +02:00
lucaronin
ec3f90e865 feat: add vault ai guidance restore flow 2026-04-14 14:16:55 +02:00
lucaronin
2356afdc42 style: format vault command wrappers 2026-04-14 12:56:16 +02:00
lucaronin
9154a8153f fix: refresh default vault agents guidance 2026-04-14 12:52:32 +02:00
lucaronin
c0fbb1c40e refactor: remove obsolete scripts tooling 2026-04-14 12:28:37 +02:00
lucaronin
76fe4611c2 test: stabilize untitled auto-rename smoke 2026-04-14 12:16:50 +02:00
lucaronin
62154535c0 feat: add ai agent status bar switcher 2026-04-14 11:55:48 +02:00
lucaronin
cff9583a46 fix: support caret boundary on current target 2026-04-14 11:37:44 +02:00
lucaronin
70252b6324 fix: anchor ai mode caret after wikilinks 2026-04-14 11:35:38 +02:00
lucaronin
60da17e13b fix: restore blocknote slash menu styling 2026-04-14 11:14:49 +02:00
lucaronin
0c9bfb14c3 fix: format async delete command 2026-04-14 10:57:32 +02:00
lucaronin
48d2246f0b fix: make note deletion non-blocking 2026-04-14 10:53:08 +02:00
lucaronin
d879457f89 docs: add ADR for ratcheted CodeScene thresholds (guard — from commit 311dcc64) 2026-04-14 10:11:05 +02:00
lucaronin
311dcc6413 chore: ratchet codescene thresholds after ADR guard checks 2026-04-14 10:03:12 +02:00
lucaronin
f61e7d2ab3 docs: update ADR for selectable CLI AI agents (guard — from commit d540d76f) 2026-04-14 09:57:20 +02:00
lucaronin
1ac6bd887b fix: format alpha build label parser 2026-04-13 23:52:13 +02:00
lucaronin
f31f827a4f fix: restore alpha build label 2026-04-13 23:48:19 +02:00
lucaronin
a1ffe7a074 fix: align note list header icons 2026-04-13 23:33:46 +02:00
lucaronin
1b45394d0a fix: show filenames in changes view 2026-04-13 23:18:53 +02:00
lucaronin
95f0153c1b fix: restore aligned property panel values 2026-04-13 23:01:37 +02:00
lucaronin
a034a79db2 fix: align blocknote editor context 2026-04-13 22:45:25 +02:00
lucaronin
1f148ebe32 fix: restore vault type command export 2026-04-13 22:23:31 +02:00
lucaronin
b7cb3c2130 fix: remove legacy journal command surfaces 2026-04-13 22:21:42 +02:00
lucaronin
f50d9127d1 fix: restore editor code block highlighting 2026-04-13 22:00:50 +02:00
lucaronin
2a353d5749 fix: keep caret after command palette ai trigger 2026-04-13 21:07:10 +02:00
lucaronin
c2f9b7c20d fix: restore ai smoke compatibility 2026-04-13 19:47:38 +02:00
lucaronin
d540d76f73 feat: support selectable cli ai agents 2026-04-13 19:37:59 +02:00
lucaronin
cdfc0c9135 docs: clarify demo vault automation hygiene 2026-04-13 17:59:23 +02:00
lucaronin
5b29e061fe fix: refresh renamed note metadata 2026-04-13 17:44:41 +02:00
lucaronin
5102923c9c fix: repair release workflow parsing 2026-04-13 17:24:03 +02:00
lucaronin
87be231f9f test: format git status assertions 2026-04-13 15:30:17 +02:00
lucaronin
557a45189d fix: show diff stats in changes list 2026-04-13 15:22:21 +02:00
lucaronin
2107764550 fix: cap properties panel label column width 2026-04-13 15:01:07 +02:00
lucaronin
0a52ae6c1d fix: prevent stale raw content after note switch 2026-04-13 14:23:20 +02:00
lucaronin
487e8d0352 fix: preserve hard breaks when toggling editors 2026-04-13 14:04:25 +02:00
lucaronin
d4aacf1bea fix: focus command palette ai mode immediately 2026-04-13 13:42:46 +02:00
lucaronin
71df363bb6 fix: satisfy command palette ai push checks 2026-04-13 13:29:06 +02:00
lucaronin
91ab5be914 fix: stabilize command palette ai mode editing 2026-04-13 13:26:38 +02:00
lucaronin
9623203756 fix: restore blocknote code block theme 2026-04-13 12:59:51 +02:00
lucaronin
5897782a7e fix: stabilize new note title focus smoke 2026-04-13 12:46:22 +02:00
lucaronin
b4abc05f0e fix: harden legacy release channel fallback 2026-04-13 12:35:02 +02:00
lucaronin
8f408d9ef0 fix: guard blocknote table handles on raw return 2026-04-13 12:19:36 +02:00
lucaronin
1d778826e8 fix: restore empty editor drag region 2026-04-13 11:52:55 +02:00
lucaronin
5120d75903 fix: update getting_started.rs AGENTS template to use type: instead of is_a: 2026-04-13 08:37:40 +02:00
lucaronin
5d7426797f docs: add ADR-0060 and ADR-0061 for network-aware UI gating and AI prompt bridge (guard — from commits 739e1b3, 7933fe1) 2026-04-13 08:09:28 +02:00
lucaronin
ed54cef69e chore: update codescene thresholds after ADR additions 2026-04-13 08:05:30 +02:00
lucaronin
df6f956cde docs: add ADR-0058 and ADR-0059 for Claude Code onboarding gate and local-only commits (guard — from commits fd9de04, a13e36a) 2026-04-13 08:01:24 +02:00
lucaronin
61aae139c7 fix: remove stale daily note menu test id 2026-04-12 23:17:17 +02:00
lucaronin
14dc07084d fix: remove daily note command surfaces 2026-04-12 23:14:03 +02:00
lucaronin
73c1ecf8a9 fix: use muted editor code surfaces 2026-04-12 22:51:32 +02:00
lucaronin
de91167a28 fix: align note list header actions 2026-04-12 22:29:36 +02:00
lucaronin
a4397d48de fix: split alpha and stable release channels 2026-04-12 22:14:53 +02:00
lucaronin
fbbd53d204 test: restore smoke flows after Claude onboarding 2026-04-12 20:10:01 +02:00
lucaronin
fd9de04275 feat: add first-launch Claude Code onboarding 2026-04-12 19:56:10 +02:00
lucaronin
a13e36a504 feat: support local-only git commits without remotes 2026-04-12 19:38:25 +02:00
lucaronin
73a63085c7 fix: tighten vault switcher typing 2026-04-12 19:19:52 +02:00
lucaronin
739e1b3c20 feat: handle offline onboarding gracefully 2026-04-12 19:17:49 +02:00
lucaronin
e2745d96eb refactor: remove github auth integration 2026-04-12 17:08:07 +02:00
lucaronin
a2c84d3ce8 test: align AI smoke selector with inline input 2026-04-12 13:20:26 +02:00
lucaronin
afbea1367c fix: narrow inline wikilink selection lookup 2026-04-12 13:15:51 +02:00
lucaronin
7933fe1bfb feat: add inline AI prompts to command palette 2026-04-12 13:14:18 +02:00
lucaronin
d16aa29d18 docs: test notes must be deleted from disk after QA; update QA script paths to tolaria-qa 2026-04-12 12:31:35 +02:00
lucaronin
6188395141 fix: align desktop test bridge types 2026-04-12 12:29:13 +02:00
lucaronin
1bf5a7d6e4 fix: preserve resized tables across raw mode toggles 2026-04-12 11:45:59 +02:00
lucaronin
27e371c7fe docs: restore README content lost during Laputa→Tolaria rename 2026-04-12 10:40:50 +02:00
lucaronin
867e97b199 fix: left-align property values in inspector 2026-04-12 02:03:54 +02:00
lucaronin
2441518cee fix: restore breadcrumb center drag region 2026-04-12 01:52:31 +02:00
lucaronin
010bc32ee1 style: format Rust rename updates 2026-04-12 01:41:26 +02:00
lucaronin
361898b187 refactor: rename app branding from Laputa to Tolaria 2026-04-12 01:35:34 +02:00
lucaronin
5b5f949c74 fix: initialize new notes with empty h1 focus 2026-04-12 00:58:11 +02:00
lucaronin
57a5693922 fix: stabilize new note editor focus 2026-04-12 00:06:49 +02:00
lucaronin
2ca8f1b2a6 fix: remove legacy title section fallback 2026-04-11 23:51:58 +02:00
lucaronin
eb65bb8f05 fix: dedupe new note command palette entries 2026-04-11 22:05:13 +02:00
lucaronin
8fb229ede3 fix: preserve square brackets in parsed note titles 2026-04-11 21:27:30 +02:00
lucaronin
ce84b34890 fix: restore cmd shift i properties shortcut 2026-04-11 20:59:16 +02:00
lucaronin
e98a186389 fix: align note list date row spacing 2026-04-11 20:30:48 +02:00
lucaronin
258b54b074 refactor: make shortcut QA modes explicit 2026-04-11 19:03:15 +02:00
lucaronin
f694b9b5e4 fix: unblock native ai panel shortcut in tauri 2026-04-11 18:34:39 +02:00
lucaronin
32ee2b781b test: stabilize keyboard shortcut smoke 2026-04-11 17:36:23 +02:00
lucaronin
c78793cfe3 fix: verify editor-focused keyboard shortcuts 2026-04-11 17:23:20 +02:00
lucaronin
cd9e5fc403 fix: guard table resize against stale editor views 2026-04-11 16:37:44 +02:00
lucaronin
798a6b2125 refactor: make shortcut execution renderer-first 2026-04-11 15:42:53 +02:00
lucaronin
dbf54657f0 fix: harden untitled h1 auto-rename flow 2026-04-11 15:22:34 +02:00
lucaronin
0c365eb7dd test: cover raw editor keyboard toggle 2026-04-11 14:23:25 +02:00
lucaronin
20b789271d fix: verify view row hover state 2026-04-11 14:06:03 +02:00
lucaronin
36d3c8731b fix: hide view counts while row actions are visible 2026-04-11 13:52:35 +02:00
lucaronin
e412fa8fd7 fix: restore breadcrumb action icon size 2026-04-11 13:33:42 +02:00
lucaronin
76c37cf783 fix: narrow shortcut dispatcher route handling 2026-04-11 13:02:31 +02:00
lucaronin
4b60b9539d refactor: centralize shortcut routing metadata 2026-04-11 12:59:11 +02:00
lucaronin
69e520b5aa fix: prevent cmd+n note creation crash 2026-04-11 12:30:05 +02:00
lucaronin
272f2c0b3c design: replace app icon with Tolaria drop icon 2026-04-11 12:12:12 +02:00
lucaronin
d7bdab5b2e fix: relax test bridge command typing 2026-04-11 11:07:52 +02:00
lucaronin
5a5ea8d6f0 fix: align test bridge window typing 2026-04-11 11:04:47 +02:00
lucaronin
86306dc9de test: stabilize keyboard menu smoke bridge 2026-04-11 10:59:38 +02:00
lucaronin
974ca6148b fix: return false for unhandled app commands 2026-04-11 10:44:45 +02:00
lucaronin
b1bc056afb refactor: unify shortcut command routing 2026-04-11 10:39:08 +02:00
lucaronin
c24f60c594 fix: restore breadcrumb action icon sizing 2026-04-10 21:22:27 +02:00
lucaronin
71a3be577d fix: always show breadcrumb filename 2026-04-10 21:08:45 +02:00
lucaronin
717fa9d1a6 fix: prefer note icons in favorite rows 2026-04-10 20:58:18 +02:00
lucaronin
78e76e0d54 fix: defer native menu shortcuts in tauri 2026-04-10 20:43:36 +02:00
lucaronin
b01c6b4a4b fix: format rename rust helpers 2026-04-10 20:25:19 +02:00
lucaronin
db4359981f fix: update rename filename wikilinks 2026-04-10 20:20:08 +02:00
lucaronin
98d19e4c41 fix: align created label to right edge in note list date row 2026-04-10 20:01:40 +02:00
lucaronin
5ebffc447b feat: standardize canonical wikilink targets 2026-04-10 19:57:21 +02:00
466 changed files with 31871 additions and 12367 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.68
AVERAGE_THRESHOLD=9.33
HOTSPOT_THRESHOLD=9.85
AVERAGE_THRESHOLD=9.61

View File

@@ -1,5 +1,10 @@
# Copy to .env.local and fill in real values
# These are never committed — .env.local is gitignored
# Copy to .env.local and fill in real values.
# These are never committed — .env.local is gitignored.
# Release workflows must mirror these values into GitHub Actions secrets:
# - VITE_SENTRY_DSN
# - SENTRY_DSN (same value as VITE_SENTRY_DSN for Rust-side crash reporting)
# - VITE_POSTHOG_KEY
# - VITE_POSTHOG_HOST
# Sentry DSN (https://sentry.io → Project → Settings → Client Keys)
VITE_SENTRY_DSN=

2
.github/HOOKS.md vendored
View File

@@ -49,7 +49,7 @@ If the remote CodeScene scores are better than the current thresholds, the hook
## Legacy Files
The legacy `pre-commit` and `post-commit` files under `.github/hooks/` are archival only. Do not copy them into `.git/hooks`; use Husky and `.husky/` instead. `install-hooks.sh` remains as a reinstall helper that runs Husky.
The legacy `pre-commit` file under `.github/hooks/` is archival only. Do not copy it into `.git/hooks`; use Husky and `.husky/` instead. The old design `post-commit` auto-implementation hook was removed because it depended on obsolete one-off scripts. `install-hooks.sh` remains as a reinstall helper that runs Husky.
## Troubleshooting

26
.github/SETUP.md vendored
View File

@@ -14,6 +14,26 @@ Nel repository GitHub (Settings → Secrets and variables → Actions → New re
**CODESCENE_PROJECT_ID**
Trova l'ID del progetto nella dashboard CodeScene (URL: `https://codescene.io/projects/<PROJECT_ID>/...`)
**VITE_SENTRY_DSN**
```
<frontend Sentry DSN used by shipped Tolaria builds>
```
**SENTRY_DSN**
```
<same DSN as VITE_SENTRY_DSN, passed to the Rust/Tauri build for native crash reporting>
```
**VITE_POSTHOG_KEY**
```
<PostHog project API key used by shipped Tolaria builds>
```
**VITE_POSTHOG_HOST**
```
https://eu.i.posthog.com
```
### 2. Enable GitHub Actions
- Vai su Settings → Actions → General
@@ -65,6 +85,12 @@ cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
- **Fail se code health diminuisce**
- Confronta HEAD vs base branch
### 📡 Telemetry In Release Builds
- `release.yml` e `release-stable.yml` devono ricevere `VITE_SENTRY_DSN`, `SENTRY_DSN`, `VITE_POSTHOG_KEY`, `VITE_POSTHOG_HOST`
- `VITE_SENTRY_DSN` inizializza il frontend Sentry bundle
- `SENTRY_DSN` inizializza Sentry nel binary Rust/Tauri
- `VITE_POSTHOG_KEY` / `VITE_POSTHOG_HOST` permettono ai build distribuiti di inizializzare PostHog quando l'utente abilita analytics
### 📝 Documentation
- **Warning se modifichi `src/` o `src-tauri/` ma non aggiorni `docs/`**
- Non blocca il merge, solo un reminder

View File

@@ -1,72 +0,0 @@
#!/bin/bash
# Post-commit hook: Auto-implement design changes via Claude Code
# Copy to .git/hooks/post-commit and make executable
set -e
# Check if ui-design.pen was modified in this commit
if ! git diff --name-only HEAD~1 HEAD | grep -q "ui-design.pen"; then
exit 0
fi
echo ""
echo "🎨 Design file changed - analyzing..."
# Run analyzer
ANALYZER_OUTPUT=$(node scripts/design-diff-analyzer.js --json 2>&1)
if [ $? -eq 0 ]; then
echo "✅ No implementation tasks needed (content-only changes)"
exit 0
fi
# Extract tasks from JSON
TASKS_JSON=$(echo "$ANALYZER_OUTPUT" | sed -n '/---JSON---/,$ p' | tail -n +2)
if [ -z "$TASKS_JSON" ]; then
echo "✅ No significant design changes"
exit 0
fi
echo "$ANALYZER_OUTPUT" | sed '/---JSON---/,$ d'
# Generate Claude Code prompt
PROMPT=$(cat <<EOF
The design file ui-design.pen was just updated. Analyze the changes and implement them.
Design changes detected:
$ANALYZER_OUTPUT
Tasks:
1. Review the git diff of ui-design.pen (HEAD vs HEAD~1)
2. Identify what changed (colors, typography, spacing, layout)
3. Update the corresponding code:
- Colors/Typography/Spacing → update src/theme.json
- Layout changes → update React components
- New elements → create new components
4. Test visually in Chrome (pnpm dev, open localhost:5173)
5. Run tests: pnpm test
6. Commit changes with descriptive message referencing the design update
When completely finished, run:
openclaw system event --text "✅ Design changes implemented and tested" --mode now
EOF
)
echo ""
echo "🚀 Spawning Claude Code to implement changes..."
echo ""
# Spawn Claude Code via OpenClaw
openclaw sessions spawn \
--task "$PROMPT" \
--label "design-auto-implement" \
--cleanup delete \
--timeout-seconds 600 \
--agent-id main
echo ""
echo "✅ Claude Code spawned - you'll be notified when implementation is complete"
echo ""
exit 0

View File

@@ -40,6 +40,18 @@ CODESCENE_PROJECT_ID=<your-project-id>
Il PAT di CodeScene è lo stesso che usi localmente (~/.codescene/token).
Il project ID lo trovi nella dashboard CodeScene.
### Telemetry Secrets For Release Builds
Aggiungi anche questi secrets per i workflow `release.yml` e `release-stable.yml`:
```
VITE_SENTRY_DSN=<frontend sentry dsn>
SENTRY_DSN=<same dsn for rust/native crash reporting>
VITE_POSTHOG_KEY=<posthog project api key>
VITE_POSTHOG_HOST=https://eu.i.posthog.com
```
Senza questi valori, i build distribuiti possono mantenere i toggle telemetry nelle Settings ma non inizializzare davvero PostHog/Sentry.
### Coverage Thresholds
Configura in `vitest.config.ts`:

View File

@@ -4,6 +4,10 @@ on:
push:
branches: [main]
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
jobs:
test:
name: Tests & Quality Checks
@@ -37,9 +41,9 @@ jobs:
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
key: ${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
${{ runner.os }}-cargo-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov

View File

@@ -1,31 +1,53 @@
name: Release (Canary)
name: Release (Stable)
on:
push:
branches:
- canary
tags:
- 'stable-v*'
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
concurrency:
group: release-canary-${{ github.ref }}
group: release-stable-${{ github.ref }}
cancel-in-progress: true
jobs:
# ─────────────────────────────────────────────────────────────
# Phase 1: Compute the canary version string
# Phase 1: Compute the stable version string once
# ─────────────────────────────────────────────────────────────
version:
name: Compute version
name: Compute stable version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
display_version: ${{ steps.ver.outputs.display_version }}
tag: ${{ steps.ver.outputs.tag }}
steps:
- id: ver
shell: bash
run: |
VERSION="0.$(date -u +%Y%m%d).${GITHUB_RUN_NUMBER}-canary"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "### Canary version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY"
python3 <<'PY' > version.env
import os
import re
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}")
date(*map(int, match.groups()))
print(f"version={version}")
print(f"display_version={version}")
print(f"tag={tag}")
PY
cat version.env >> "$GITHUB_OUTPUT"
DISPLAY_VERSION=$(grep '^display_version=' version.env | cut -d= -f2-)
echo "### Stable version: \`$DISPLAY_VERSION\`" >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# Phase 2: Build each architecture in parallel
@@ -71,13 +93,17 @@ jobs:
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-
${{ 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 }}"
@@ -111,6 +137,10 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
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: |
pnpm tauri build --target ${{ matrix.target }}
@@ -131,10 +161,10 @@ jobs:
retention-days: 1
# ─────────────────────────────────────────────────────────────
# Phase 3: Publish GitHub Release (prerelease)
# Phase 3: Publish GitHub Release
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release (canary)
name: GitHub Release (stable)
needs: [version, build]
runs-on: ubuntu-latest
permissions:
@@ -149,38 +179,40 @@ jobs:
- name: Generate release notes
run: |
PREV_TAG=$(git tag --sort=-version:refname | grep canary | head -n 1 || echo "")
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)
else
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..HEAD")
NOTES=$(git log --oneline --no-merges "${PREV_TAG}..${{ needs.version.outputs.tag }}")
fi
{
echo "## What's Changed (Canary)"
echo "## What's Changed"
echo ""
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
echo ""
echo "---"
echo "**Canary build — pre-release, may be unstable**"
echo "**Stable release manually promoted from \`main\`**"
echo ""
echo "**Requires Apple Silicon (M1/M2/M3)**"
echo ""
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
echo "*Built from \`$(git rev-parse --short ${{ needs.version.outputs.tag }})\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
- name: Build latest-canary.json
- name: Build stable-latest.json
run: |
VERSION="${{ needs.version.outputs.version }}"
TAG="${{ needs.version.outputs.tag }}"
REPO="refactoringhq/laputa-app"
REPO="${GITHUB_REPOSITORY}"
REPO_NAME="${REPO#*/}"
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
cat > latest-canary.json << EOF
cat > stable-latest.json << EOF
{
"version": "${VERSION}",
"notes": "Canary build. See https://refactoringhq.github.io/laputa-app/ for release notes.",
"notes": "Stable release. See ${PAGES_URL} for full release notes.",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"darwin-aarch64": {
@@ -190,24 +222,24 @@ jobs:
}
}
EOF
echo "latest-canary.json:"; cat latest-canary.json
echo "stable-latest.json:"; cat stable-latest.json
- name: Publish GitHub Release (prerelease)
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: Laputa ${{ needs.version.outputs.version }} (Canary)
name: Tolaria ${{ needs.version.outputs.display_version }}
body_path: release_notes.md
draft: false
prerelease: true
prerelease: false
files: |
dmg-aarch64/*.dmg
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
latest-canary.json
stable-latest.json
# ─────────────────────────────────────────────────────────────
# Phase 4: Update GitHub Pages (preserve stable latest.json)
# Phase 4: Update GitHub Pages
# ─────────────────────────────────────────────────────────────
pages:
name: Update release history page
@@ -225,19 +257,23 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site
mkdir -p _site/alpha _site/stable
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
# Download stable latest.json from existing GH Pages (preserve it)
curl -fsSL "https://refactoringhq.github.io/laputa-app/latest.json" -o _site/latest.json || echo '{}' > _site/latest.json
# Copy canary latest.json from this release
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "latest-canary.json" --output _site/latest-canary.json || true
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
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json
cat > _site/index.html << 'HTMLEOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laputa — Release History</title>
<title>Tolaria — Release History</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
@@ -250,13 +286,13 @@ jobs:
.release .downloads { margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.release .downloads a { display: inline-block; padding: 0.375rem 0.75rem; background: #155DFF; color: #fff; border-radius: 6px; text-decoration: none; font-size: 0.8125rem; font-weight: 500; }
.release .downloads a:hover { background: #1248CC; }
.canary { border-left: 3px solid #f59e0b; }
.alpha { border-left: 3px solid #f59e0b; }
.empty { color: #787774; text-align: center; padding: 3rem; }
</style>
</head>
<body>
<h1>Laputa Release History</h1>
<p class="subtitle">Auto-updated on every release</p>
<h1>Tolaria Release History</h1>
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
<div id="releases"></div>
<script>
fetch('releases.json').then(r=>r.json()).then(releases=>{
@@ -268,8 +304,8 @@ jobs:
const links=dmgs.map(a=>'<a href="'+a.browser_download_url+'">'+a.name+'</a>').join('');
const body=(r.body||'').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const div=document.createElement('div');
div.className='release'+(r.prerelease?' canary':'');
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+(r.prerelease?' · <strong>Canary</strong>':'')+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
div.className='release'+(r.prerelease?' alpha':'');
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+(r.prerelease?' · <strong>Alpha</strong>':'')+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
el.appendChild(div);
});
});
@@ -283,4 +319,4 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./_site
commit_message: "Update release history for canary ${{ needs.version.outputs.tag }}"
commit_message: "Update release history for ${{ needs.version.outputs.tag }}"

View File

@@ -1,31 +1,95 @@
name: Release
name: Release (Alpha)
on:
push:
branches:
- main
env:
# Bump this when Tauri/Rust target artifacts capture stale absolute paths.
RUST_TARGET_CACHE_VERSION: v2026-04-14-tolaria
concurrency:
group: release-${{ github.ref }}
group: release-alpha-${{ github.ref }}
cancel-in-progress: true
jobs:
# ─────────────────────────────────────────────────────────────
# Phase 1: Compute the version string once
# Phase 1: Compute the alpha version string once
# Alpha builds use calendar semver and stay newer than the latest stable tag.
# ─────────────────────────────────────────────────────────────
version:
name: Compute version
name: Compute alpha version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
display_version: ${{ steps.ver.outputs.display_version }}
tag: ${{ steps.ver.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: ver
shell: bash
run: |
VERSION="0.$(date -u +%Y%m%d).${GITHUB_RUN_NUMBER}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
echo "### Version: \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY"
python3 <<'PY' > version.env
import re
import subprocess
from datetime import datetime, timedelta, timezone
def lines(command: list[str]) -> list[str]:
output = subprocess.check_output(command, text=True).strip()
return [line for line in output.splitlines() if line]
existing_tags = [
tag for tag in lines(["git", "tag", "--points-at", "HEAD"])
if tag.startswith("alpha-v")
]
if existing_tags:
tag = existing_tags[0]
version = tag.removeprefix("alpha-v")
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})$")
for stable_tag in lines(["git", "tag", "--list", "stable-v*", "--sort=-version:refname"]):
match = stable_pattern.fullmatch(stable_tag)
if not match:
continue
year, month, day = map(int, match.groups())
try:
stable_date = datetime(year, month, day, tzinfo=timezone.utc).date()
except ValueError:
continue
break
alpha_date = today if stable_date is None or today > stable_date else stable_date + timedelta(days=1)
calendar_version = f"{alpha_date.year}.{alpha_date.month}.{alpha_date.day}"
sequence = len(lines(["git", "tag", "--list", f"alpha-v{calendar_version}-alpha.*"])) + 1
version = f"{calendar_version}-alpha.{sequence}"
tag = f"alpha-v{version}"
display_match = re.fullmatch(r"(\d{4})\.(\d{1,2})\.(\d{1,2})-alpha\.(\d+)", version)
display_version = (
f"Alpha {int(display_match.group(1))}.{int(display_match.group(2))}.{int(display_match.group(3))}.{int(display_match.group(4))}"
if display_match
else version
)
print(f"version={version}")
print(f"display_version={display_version}")
print(f"tag={tag}")
PY
cat version.env >> "$GITHUB_OUTPUT"
VERSION=$(grep '^version=' version.env | cut -d= -f2-)
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
@@ -72,13 +136,17 @@ jobs:
~/.cargo/registry
~/.cargo/git
src-tauri/target
key: ${{ runner.os }}-release-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
key: ${{ runner.os }}-release-cargo-${{ matrix.target }}-${{ env.RUST_TARGET_CACHE_VERSION }}-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-release-cargo-
${{ 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 }}"
@@ -90,7 +158,6 @@ jobs:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
# Decode and import the certificate so codesign can use it in beforeBuildCommand
CERT_PATH="$RUNNER_TEMP/apple_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/laputa-signing.keychain-db"
KEYCHAIN_PASSWORD="$(uuidgen)"
@@ -113,15 +180,14 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
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: |
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
# 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
@@ -137,7 +203,7 @@ jobs:
# No lipo/re-signing — use the per-arch artifacts directly
# ─────────────────────────────────────────────────────────────
release:
name: GitHub Release
name: GitHub Release (alpha)
needs: [version, build]
runs-on: ubuntu-latest
permissions:
@@ -152,36 +218,40 @@ jobs:
- name: Generate release notes
run: |
PREV_TAG=$(git tag --sort=-version:refname | head -n 1 || echo "")
PREV_TAG=$(git tag --list 'alpha-v*' --sort=-version:refname | 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}..HEAD")
fi
{
echo "## What's Changed"
echo "## What's Changed (Alpha)"
echo ""
echo "$NOTES" | while IFS= read -r line; do echo "- $line"; done
echo ""
echo "---"
echo "**Alpha build — updated on every push to \`main\`**"
echo ""
echo "**Requires Apple Silicon (M1/M2/M3)**"
echo ""
echo "*Built from \`$(git rev-parse --short HEAD)\` on $(date -u +%Y-%m-%d)*"
} > release_notes.md
- name: Build latest.json
- name: Build alpha-latest.json
run: |
VERSION="${{ needs.version.outputs.version }}"
TAG="${{ needs.version.outputs.tag }}"
REPO="refactoringhq/laputa-app"
REPO="${GITHUB_REPOSITORY}"
REPO_NAME="${REPO#*/}"
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
cat > latest.json << EOF
cat > alpha-latest.json << EOF
{
"version": "${VERSION}",
"notes": "See https://refactoringhq.github.io/laputa-app/ for full release notes.",
"notes": "Alpha build. See ${PAGES_URL} for full release notes.",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"darwin-aarch64": {
@@ -191,21 +261,20 @@ jobs:
}
}
EOF
echo "latest.json:"; cat latest.json
echo "alpha-latest.json:"; cat alpha-latest.json
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: Laputa ${{ needs.version.outputs.version }}
name: Tolaria ${{ needs.version.outputs.display_version }}
body_path: release_notes.md
draft: false
prerelease: false
prerelease: true
files: |
dmg-aarch64/*.dmg
updater-aarch64/*.app.tar.gz
updater-aarch64/*.app.tar.gz.sig
latest.json
alpha-latest.json
# ─────────────────────────────────────────────────────────────
# Phase 4: Update GitHub Pages with release history
@@ -226,19 +295,23 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p _site
mkdir -p _site/alpha _site/stable
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
# Copy latest.json to GitHub Pages for auto-updater endpoint
gh release download --repo ${{ github.repository }} --pattern "latest.json" --output _site/latest.json || true
# Preserve canary latest.json from existing GH Pages
curl -fsSL "https://refactoringhq.github.io/laputa-app/latest-canary.json" -o _site/latest-canary.json || true
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
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
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json
cat > _site/index.html << 'HTMLEOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laputa — Release History</title>
<title>Tolaria — Release History</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
@@ -251,12 +324,13 @@ jobs:
.release .downloads { margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.release .downloads a { display: inline-block; padding: 0.375rem 0.75rem; background: #155DFF; color: #fff; border-radius: 6px; text-decoration: none; font-size: 0.8125rem; font-weight: 500; }
.release .downloads a:hover { background: #1248CC; }
.alpha { border-left: 3px solid #f59e0b; }
.empty { color: #787774; text-align: center; padding: 3rem; }
</style>
</head>
<body>
<h1>Laputa Release History</h1>
<p class="subtitle">Auto-updated on every release</p>
<h1>Tolaria Release History</h1>
<p class="subtitle">Alpha builds update on every push to main. Stable builds appear when a stable-vYYYY.M.D tag is promoted.</p>
<div id="releases"></div>
<script>
fetch('releases.json').then(r=>r.json()).then(releases=>{
@@ -268,8 +342,8 @@ jobs:
const links=dmgs.map(a=>'<a href="'+a.browser_download_url+'">'+a.name+'</a>').join('');
const body=(r.body||'').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const div=document.createElement('div');
div.className='release';
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
div.className='release'+(r.prerelease?' alpha':'');
div.innerHTML='<h2>'+(r.name||r.tag_name)+'</h2><div class="meta">'+date+' · '+r.tag_name+(r.prerelease?' · <strong>Alpha</strong>':'')+'</div><div class="body">'+body+'</div>'+(links?'<div class="downloads">'+links+'</div>':'');
el.appendChild(div);
});
});

View File

@@ -1,4 +1,4 @@
# AGENTS.md — Laputa App
# AGENTS.md — Tolaria App
> Quick links: [Project Spec](docs/PROJECT-SPEC.md) · [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
@@ -41,8 +41,8 @@ BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
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.
@@ -105,9 +105,22 @@ After any Tauri command, new component/hook, data model change, or new integrati
## 3. Product Rules
### Demo vault hygiene (`demo-vault/`, `demo-vault-v2/`)
Default to `demo-vault-v2/` for testing.
- Treat `demo-vault/` and `demo-vault-v2/` as disposable QA fixtures unless the task explicitly changes demo content.
- If you create untracked notes, attachments, or other temporary files there for testing, delete them before the task is complete.
- If you modify tracked demo-vault files only to test or QA behavior, revert those edits before the final commit.
- Before declaring a task done, make sure `git status --short -- demo-vault demo-vault-v2` is empty unless demo fixture changes are part of the task.
- If a fresh run starts and the only local dirt is inside `demo-vault/` or `demo-vault-v2/`, clean those paths first and continue. That case is recoverable QA residue, not a blocker.
### User vault (`~/Laputa/`)
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
- **Never commit or push** any test notes to the remote vault
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
### UI design
@@ -130,7 +143,7 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
| Dialog/modal | `Dialog` from shadcn/ui |
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Laputa — if it looks like a browser default, it's wrong.
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Tolaria — if it looks like a browser default, it's wrong.
---
@@ -145,9 +158,9 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
### QA scripts
```bash
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
```
### Diagrams

View File

@@ -1,4 +1,4 @@
# Laputa App
# Tolaria App
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.

View File

@@ -1,20 +1,20 @@
# Abstractions
Key abstractions and domain models in Laputa.
Key abstractions and domain models in Tolaria.
## Design Philosophy
Laputa's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
Tolaria's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
The full set of design principles is documented in [ARCHITECTURE.md](./ARCHITECTURE.md#design-principles).
## Semantic Field Names (conventions)
These frontmatter field names have special meaning in Laputa's UI:
These frontmatter field names have special meaning in Tolaria's UI:
| Field | Meaning | UI behavior |
|---|---|---|
| `title:` | Human-readable title (synced with filename) | Breadcrumb, sidebar. Filename = `slugify(title).md` |
| `title:` | Legacy display-title fallback for older notes | Used only when a note has no H1; new notes do not write it automatically |
| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping |
| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header |
| `icon:` | Per-note icon (emoji, Phosphor name, or HTTP/HTTPS image URL) | Rendered on note title surfaces; editable from the Properties panel |
@@ -23,10 +23,11 @@ These frontmatter field names have special meaning in Laputa's UI:
| `start_date:` + `end_date:` | Duration/timespan | Date range badge |
| `goal:` + `result:` | Progress | Progress indicator in editor header |
| `Workspace:` | Vault context filter | Global workspace filter |
| `Belongs to:` | Parent relationship | Relationship chip in Properties panel |
| `Related to:` | Lateral relationship | Relationship chip in Properties panel |
| `belongs_to:` | Parent relationship | Humanized to `Belongs to` in the UI |
| `related_to:` | Lateral relationship | Humanized to `Related to` in the UI |
| `has:` | Contained relationship | Humanized to `Has` in the UI |
Relationship fields are detected dynamically — any frontmatter field containing `[[wikilink]]` values is treated as a relationship (see [ADR-0010](adr/0010-dynamic-wikilink-relationship-detection.md)).
Relationship fields are detected dynamically — any frontmatter field containing `[[wikilink]]` values is treated as a relationship (see [ADR-0010](adr/0010-dynamic-wikilink-relationship-detection.md)). Tolaria's own default relationship vocabulary uses snake_case on disk, but labels are humanized at render time and existing user-authored keys are left untouched.
### System Properties (underscore convention)
@@ -35,7 +36,7 @@ Any frontmatter field whose name starts with `_` is a **system property**:
- It is **not shown** in the Properties panel (neither for notes nor for Type notes)
- It is **not exposed** as a user-visible property in search, filters, or the UI
- It **is editable** directly in the raw editor (power users can access it if needed)
- It is used by Laputa internally for configuration, behavior, and UI preferences
- It is used by Tolaria internally for configuration, behavior, and UI preferences
Examples:
```yaml
@@ -146,12 +147,13 @@ Type is determined **purely** from the `type:` frontmatter field — it is never
├── weekly-review.md ← type: Procedure
├── john-doe.md ← type: Person
├── some-topic.md ← type: Topic
├── AGENTS.md ← canonical Tolaria AI guidance
├── CLAUDE.md ← compatibility shim pointing at AGENTS.md
├── ...
── type/ ← type definition documents
└── config/ ← meta-configuration files (agents.md, etc.)
── type/ ← type definition documents
```
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents, and `config/` for configuration files.
New notes are created at the vault root: `{vault}/{slug}.md`. Changing a note's type only requires updating the `type:` field in frontmatter — the file does not move. The `type/` folder exists solely for type definition documents. Legacy `config/` content is still recognized during migration and repair, but Tolaria's managed AI guidance now lives at the vault root.
A `flatten_vault` migration command is available to move existing notes from type-based subfolders to the vault root.
@@ -233,20 +235,21 @@ All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex an
### Title / Filename Sync
Laputa separates **display title** from the file identifier:
Tolaria separates **display title** from the file identifier:
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
- **On rename / explicit title edits** (`rename_note`): Laputa updates both filename and `title` frontmatter atomically, plus wikilinks across the vault.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions update the filename and wikilinks across the vault. The editor body remains the title editing surface.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Field (UI)
### Title Surface (UI)
The dedicated `TitleField` is a fallback editing surface, not the canonical one:
The BlockNote body is the only title editing surface:
- If the note already has an H1, the editor body is the primary title surface and the dedicated title row is hidden.
- If the note has no H1 and is not an untitled draft, `TitleField` appears above the editor and `onTitleSync` updates `title:` frontmatter plus the filename.
- `TitleField` also responds to `laputa:focus-editor` events with `selectTitle: true` for new-note flows that start without an H1.
- The first H1 is the canonical display title.
- There is no separate title row above the editor, even when a note has no H1.
- Notes without an H1 show the editor body and placeholder only.
- Filename changes are explicit breadcrumb actions, not a dedicated title-input side effect.
### Sidebar Selection
@@ -270,7 +273,7 @@ type SidebarSelection =
1. Validates the path exists and is a directory
2. Scans root-level `.md` files (non-recursive)
3. Recursively scans protected folders: `type/`, `config/`, `attachments/`
3. Recursively scans protected folders: `type/`, legacy `config/`, `attachments/`
4. Files in non-protected subfolders are **not indexed** (flat vault enforcement)
5. For each `.md` file, calls `parse_md_file()`:
- Reads content with `fs::read_to_string()`
@@ -335,6 +338,13 @@ interface ModifiedFile {
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
}
interface GitRemoteStatus {
branch: string
ahead: number
behind: number
hasRemote: boolean
}
interface PulseCommit {
hash: string
shortHash: string
@@ -371,12 +381,18 @@ interface PulseCommit {
- `pullAndPush()`: pulls then auto-pushes for divergence recovery
- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs)
`useGitRemoteStatus` is the commit-time companion to `useAutoSync`:
- Re-checks `git_remote_status` when the Commit dialog opens and right before submit
- Converts `hasRemote: false` into a local-only commit path
- Keeps the normal push path unchanged for vaults that do have a remote
### Frontend Integration
- **Modified file badges**: Orange dots in sidebar
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
- **Git history**: Shown in Inspector panel for active note
- **Commit dialog**: Triggered from sidebar or Cmd+K
- **No remote indicator**: Neutral chip in the bottom bar when `GitRemoteStatus.hasRemote === false`
- **Pulse view**: Activity feed when Pulse filter is selected
- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu
- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button
@@ -401,6 +417,23 @@ const WikiLink = createReactInlineContentSpec(
)
```
### Code Block Highlighting
Defined in `src/components/editorSchema.tsx` and styled in `src/components/EditorTheme.css`:
- The schema overrides BlockNote's default `codeBlock` spec with `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })` from `@blocknote/code-block`.
- Fenced code blocks now use BlockNote's supported Shiki-backed highlighter path, which renders `.shiki` token spans directly inside the editor DOM.
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript while still supporting the packaged language aliases such as `ts``typescript`.
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep BlockNote's dark shell instead of inheriting the muted inline surface.
### Formatting Surface Policy
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
- `SingleEditorView` disables BlockNote's default formatting toolbar and `/` menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
### Markdown-to-BlockNote Pipeline
```mermaid
@@ -459,7 +492,7 @@ The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, and all custom relationship fields as clickable wikilink chips.
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, `has`, and all custom relationship fields as clickable wikilink chips. Relationship labels are humanized for display, but stored keys remain unchanged.
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
@@ -494,7 +527,7 @@ No indexing step required — search runs directly against the filesystem.
### Vault Switching
`useVaultSwitcher` hook manages multiple vaults:
- Persists vault list to `~/.config/com.laputa.app/vaults.json`
- Persists vault list to `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade)
- Switching closes all tabs and resets sidebar
- Supports adding, removing, hiding/restoring vaults
- Default vault: Getting Started demo vault
@@ -506,36 +539,57 @@ Per-vault settings stored locally and scoped by vault path:
- Settings: zoom, view mode, tag colors, status colors, property display modes, Inbox note-list column overrides, explicit organization workflow toggle
- One-time migration from localStorage (`configMigration.ts`)
### AI Guidance Files
Tolaria tracks managed vault-level AI guidance separately from normal note content:
- `AGENTS.md` is the canonical managed guidance file for Tolaria-aware coding agents
- `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`
- `useVaultAiGuidanceStatus` reads `get_vault_ai_guidance_status` and normalizes the backend state into four UI cases: `managed`, `missing`, `broken`, and `custom`
- `restore_vault_ai_guidance` repairs only Tolaria-managed files; user-authored custom `AGENTS.md` / `CLAUDE.md` files are surfaced as custom and left untouched
- The status bar AI badge and command palette consume that abstraction to expose restore actions only when the managed guidance is missing or broken
### Getting Started / Onboarding
`useOnboarding` hook detects first launch:
- If vault path doesn't exist → show `WelcomeScreen`
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
- Welcome state tracked in localStorage (`laputa_welcome_dismissed`)
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen parent folder; Tolaria derives the final `Getting Started` child path before cloning
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
### GitHub Integration
`useGettingStartedClone` encapsulates the non-onboarding Getting Started action:
- Opens the same parent-folder picker used by onboarding
- Derives the final `.../Getting Started` destination path
- Surfaces the resolved path through the app toast after a successful clone
Device Authorization Flow for GitHub-backed vaults:
- `GitHubDeviceFlow` component handles OAuth
- `GitHubVaultModal` for cloning existing repos or creating new ones
- Token persisted in app settings for future git operations
- `SettingsPanel` shows connection status with disconnect option
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
- Only shows after vault onboarding has already resolved to a ready state
- Persists dismissal locally once the user continues
### Remote Git Operations
Tolaria delegates remote auth to the user's system git setup:
- `CloneVaultModal` captures a remote URL and local destination
- `clone_repo` shells out to system git for clone operations
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors
- No provider-specific token or username is stored in app settings
## Settings
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads legacy `com.laputa.app` on upgrade):
```typescript
interface Settings {
openai_key: string | null
google_key: string | null
github_token: string | null
github_username: string | null
auto_pull_interval_minutes: number | null
telemetry_consent: boolean | null
crash_reporting_enabled: boolean | null
analytics_enabled: boolean | null
anonymous_id: string | null
release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed
default_ai_agent: 'claude_code' | 'codex' | null
}
```
Managed by `useSettings` hook and `SettingsPanel` component.
Managed by `useSettings` hook and `SettingsPanel` component. `default_ai_agent` is an installation-local preference that selects which supported CLI agent the AI panel, command palette AI mode, and status bar should target by default.
## Telemetry
@@ -558,8 +612,22 @@ Managed by `useSettings` hook and `SettingsPanel` component.
## Updates & Feature Flags
### Hooks
- **`useUpdater()`** — Checks for updates using the Tauri updater plugin. Automatic download and install.
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to compile-time default. Type-safe via `FeatureFlagName` union.
- **`useUpdater(releaseChannel)`** — Channel-aware updater state machine. Checks the selected feed, surfaces available/downloading/ready states, and delegates install work to Rust.
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to telemetry-backed evaluation. Type-safe via `FeatureFlagName` union.
### Frontend helpers
- **`src/lib/releaseChannel.ts`** — Normalizes persisted channel values so legacy or invalid settings fall back to Stable, while Stable serializes back to `null`.
- **`src/lib/appUpdater.ts`** — Thin wrapper around the Tauri updater commands. Keeps the React hook free of endpoint-selection details.
### Rust
- **`src-tauri/src/app_updater.rs`** — Chooses the correct update endpoint (`alpha/latest.json` or `stable/latest.json`) and adapts Tauri updater results into frontend-friendly payloads.
- **`src-tauri/src/commands/version.rs`** — Formats app build/version labels for the status bar, including calendar alpha labels and legacy release compatibility.
### Tauri Commands
- **`check_for_app_update`** — Channel-aware update manifest lookup.
- **`download_and_install_app_update`** — Channel-aware download/install with streamed progress events.
### CI/CD
- **`.github/workflows/release.yml`** — Stable builds from `main`. Produces `latest.json` on GitHub Pages.
- **`.github/workflows/release.yml`** — Alpha prereleases from every push to `main` using calendar-semver technical versions (`YYYY.M.D-alpha.N`) and clean `Alpha YYYY.M.D.N` release names. Publishes `alpha/latest.json` and refreshes the legacy `latest.json` / `latest-canary.json` aliases to the alpha feed.
- **`.github/workflows/release-stable.yml`** — Stable releases from `stable-vYYYY.M.D` tags. Publishes `stable/latest.json`.
- **Beta cohorts** are handled in PostHog targeting only. There is no beta updater feed.

View File

@@ -1,6 +1,6 @@
# Architecture
Laputa is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
Tolaria is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
## Design Principles
@@ -10,23 +10,23 @@ The vault is a folder of plain markdown files. The app never owns the data — i
### Convention over configuration
Laputa is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
Tolaria is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `belongs_to:`, `related_to:`, `has:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. Relationship defaults are stored in snake_case on disk and humanized in the UI. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
This principle directly serves AI-readability: the more structure comes from shared conventions rather than per-user custom configurations, the easier it is for an AI agent to understand and navigate the vault correctly — without needing bespoke instructions for every setup.
### Where to store state: vault vs. app settings
When deciding where to persist a piece of data, ask: **"Would the user want this to follow them across all their Laputa installations — other devices, future platforms (tablet, web)?"**
When deciding where to persist a piece of data, ask: **"Would the user want this to follow them across all their Tolaria installations — other devices, future platforms (tablet, web)?"**
| Follows the vault | Stays with the installation |
|-------------------|-----------------------------|
| Type icon, type color | Editor zoom level |
| Pinned properties per type | API keys (OpenAI, Google) |
| Sidebar label overrides | GitHub token |
| Sidebar label overrides | Auto-sync interval |
| Property display order | Window size / position |
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.laputa.app/settings.json` or localStorage.
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
Examples:
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
@@ -89,6 +89,7 @@ flowchart LR
| Desktop shell | Tauri v2 | 2.10.0 |
| Frontend | React + TypeScript | React 19, TS 5.9 |
| Editor | BlockNote | 0.46.2 |
| Code block highlighting | @blocknote/code-block | 0.46.2 |
| Raw editor | CodeMirror 6 | - |
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
| UI primitives | Radix UI + shadcn/ui | - |
@@ -96,7 +97,7 @@ flowchart LR
| Build | Vite | 7.3.1 |
| Backend language | Rust (edition 2021) | 1.77.2 |
| Frontmatter parsing | gray_matter | 0.2 |
| AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - |
| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - |
| Search | Keyword (walkdir-based file scan) | - |
| MCP | @modelcontextprotocol/sdk | 1.0 |
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
@@ -114,7 +115,7 @@ flowchart TD
NL["NoteList / PulseView\n(filtered list / activity)"]
ED["Editor\n(BlockNote + diff + raw)"]
IN["Inspector\n(metadata + relationships)"]
AIP["AiPanel\n(Claude CLI agent + tools)"]
AIP["AiPanel\n(selected CLI agent + tools)"]
SP["SearchPanel\n(keyword search)"]
ST["StatusBar\n(vault picker + sync + version)"]
CP["CommandPalette\n(Cmd+K launcher)"]
@@ -124,24 +125,27 @@ flowchart TD
end
subgraph RB["Rust Backend"]
LIB["lib.rs → 64 Tauri commands"]
LIB["lib.rs → Tauri commands"]
VAULT["vault/"]
FM["frontmatter/"]
GIT["git/"]
GH["github/"]
GIT["git/\n(commit, sync, clone)"]
SETTINGS["settings.rs"]
SEARCH["search.rs"]
CLI["claude_cli.rs"]
CLI["ai_agents.rs\n+ claude_cli.rs"]
end
subgraph EXT["External Services"]
CCLI["Claude CLI\n(agent subprocess)"]
CCLI["Claude CLI / Codex CLI\n(agent subprocesses)"]
MCP["MCP Server\n(ws://9710, 9711)"]
GHAPI["GitHub API\n(OAuth, repos, clone)"]
GCLI["git CLI\n(system executable)"]
REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"]
end
FE -->|"Tauri IPC"| RB
FE -->|"Vite Proxy / WS"| EXT
CLI -->|"spawn subprocess"| CCLI
LIB -->|"register / monitor"| MCP
GIT -->|"clone / fetch / push / pull"| GCLI
GCLI -->|"network auth via user config"| REMOTE
end
style FE fill:#e8f4fd,stroke:#2196f3,color:#000
@@ -174,11 +178,13 @@ flowchart TD
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing.
## Multi-Window (Note Windows)
Notes can be opened in separate Tauri windows for focused editing. Secondary windows show only the editor panel (no sidebar, no note list).
@@ -201,30 +207,32 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win
### AI Agent (AiPanel)
Full agent mode — spawns Claude CLI as a subprocess with tool access and MCP vault integration.
Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration.
1. **Frontend** (`AiPanel` + `useAiAgent` hook) — streaming UI with reasoning blocks, tool action cards, and response display
2. **Backend** (`claude_cli.rs`) — spawns `claude` binary with `--output-format stream-json`, parses NDJSON events
3. **MCP Integration** — passes vault MCP config via `--mcp-config` flag so the agent can search, read, and modify vault notes
1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection
2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters
3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json`
4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides
#### Agent Event Flow
```mermaid
sequenceDiagram
participant U as User (AiPanel)
participant FE as useAiAgent (Frontend)
participant R as claude_cli.rs (Rust)
participant C as Claude CLI
participant FE as useCliAiAgent (Frontend)
participant R as ai_agents.rs (Rust)
participant C as Selected CLI Agent
participant V as Vault (MCP)
U->>FE: sendMessage(text, references)
FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs)
FE->>R: invoke('stream_claude_agent', {message, systemPrompt, vaultPath})
R->>C: spawn claude -p <msg> --output-format stream-json --mcp-config <json>
FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath})
R->>R: pick adapter for claude_code or codex
R->>C: spawn agent with MCP-enabled config
loop NDJSON stream
C-->>R: Init | TextDelta | ThinkingDelta | ToolStart | ToolDone | Result | Done
R-->>FE: emit("claude-agent-stream", event)
loop Normalized stream
C-->>R: Claude NDJSON or Codex JSONL events
R-->>FE: emit("ai-agent-stream", event)
alt TextDelta
FE->>FE: accumulate response (revealed on Done)
else ThinkingDelta
@@ -245,7 +253,7 @@ sequenceDiagram
#### File Operation Detection
When the agent writes or edits vault files, `useAiAgent` detects this from tool inputs (Write/Edit tool JSON) and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
When the agent writes or edits vault files, `useCliAiAgent` detects this from normalized tool inputs and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
### Context Building
@@ -265,7 +273,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri
### Authentication
Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Laputa.
Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed. Tolaria does not store model-provider API keys in app settings.
## MCP Server
@@ -285,7 +293,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
| `ui_open_note` | `path` | Open a note in the Tolaria UI editor |
| `ui_open_tab` | `path` | Open a note in a new UI tab |
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
@@ -293,13 +301,13 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
### Transports
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
- **WebSocket** — live bridge for Laputa app integration:
- **WebSocket** — live bridge for Tolaria app integration:
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
### Auto-Registration
On app startup, Laputa automatically registers itself as an MCP server in:
On app startup, Tolaria automatically registers itself as an MCP server in:
- `~/.claude/mcp.json` (Claude Code)
- `~/.cursor/mcp.json` (Cursor)
@@ -349,7 +357,7 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `register_mcp(vault_path)` | Writes Laputa entry to Claude Code and Cursor MCP configs |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
@@ -400,7 +408,7 @@ The app uses a single light theme with no user-configurable theming (see [ADR-00
### Vault List
Persisted at `~/.config/com.laputa.app/vaults.json`:
Persisted at `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade):
```json
{
"vaults": [{ "label": "My Vault", "path": "/path/to/vault" }],
@@ -420,6 +428,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
- `tag_colors`, `status_colors`: Custom color overrides
- `property_display_modes`: Property display preferences
- `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list
- `allNotes.noteListProperties`: Optional All Notes-only property chip override for the note list
- `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection
### Getting Started Vault
@@ -427,25 +436,29 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options:
- **Create a new vault** → creates an empty git repo in a folder the user chooses
- **Open an existing folder** → system file picker
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public GitHub URL and delegates the actual clone to the existing git backend.
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
### GitHub OAuth Integration
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
### Remote Clone & Auth Model
Tolaria no longer implements provider-specific OAuth or remote-repository APIs. All remote git work goes through the user's existing system git configuration.
**Flow:**
1. User clicks "Login with GitHub" in Settings panel
2. `github_device_flow_start()` returns a user code + verification URL
3. User authorizes at `github.com/login/device`
4. App polls `github_device_flow_poll()` until authorized
5. Token stored in `~/.config/com.laputa.app/settings.json`
1. User opens `CloneVaultModal` from onboarding or the vault menu
2. User pastes any git URL and chooses a local destination
3. `clone_repo()` shells out to `git clone`
4. `git_push()` / `git_pull()` continue to use the same system git path
5. If auth fails, the raw git stderr is surfaced in the UI
**Vault operations:**
- `GitHubVaultModal`: Clone existing repo or create new private/public repo
- `clone_repo()`: Clones with token-injected HTTPS URL
- Token persists for future git push/pull operations
**Auth model:**
- SSH keys, Git Credential Manager, macOS Keychain helpers, `gh auth`, and other git helpers all work without app-specific setup
- No provider tokens are stored in Tolaria settings
- The same flow works for GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes
## Pulse View
@@ -519,8 +532,13 @@ flowchart TD
PC -->|Fast-forward| RV["reload vault"]
PC -->|Up to date| DONE["idle"]
MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"]
GC --> GP["invoke('git_push')"]
MAN["Manual commit\n(CommitDialog)"] --> RS["useGitRemoteStatus\n(commit-time check)"]
RS --> RCHK["invoke('git_remote_status')"]
RCHK --> RMODE{Remote configured?}
RMODE -->|No| GC["invoke('git_commit', message)"]
GC --> LOCAL["Local commit only\nNo remote chip + local toast"]
RMODE -->|Yes| GC2["invoke('git_commit', message)"]
GC2 --> GP["invoke('git_push')"]
GP --> PR{Push result?}
PR -->|ok| RM["Reload modified files"]
PR -->|rejected| DIV["syncStatus = pull_required"]
@@ -533,6 +551,8 @@ flowchart TD
STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"]
```
`useGitRemoteStatus` re-checks `git_remote_status` when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps the flow local-only: the status bar shows a neutral `No remote` chip, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted.
#### Sync States
| State | Indicator | Color | Trigger |
@@ -556,7 +576,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing config files |
| `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules
@@ -565,10 +585,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|--------|---------|
| `vault/` | Vault scanning, caching, parsing, rename, image, migration |
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`) |
| `search.rs` | Keyword search — walkdir-based vault file scan |
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch |
| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing |
| `mcp.rs` | MCP server spawning + config registration |
| `commands/` | Tauri command handlers (split into submodules) |
| `settings.rs` | App settings persistence |
@@ -576,7 +596,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `vault_list.rs` | Vault list persistence |
| `menu.rs` | Native macOS menu bar |
## Tauri IPC Commands (65 total)
## Tauri IPC Commands
### Vault Operations
@@ -593,7 +613,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_getting_started_vault` | Clone the public Getting Started vault into a chosen local folder |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, and `config.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
### Frontmatter
@@ -620,17 +643,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `get_conflict_mode` | Get conflict resolution mode |
| `get_vault_pulse` | Git activity feed (paginated) |
| `get_last_commit_info` | Latest commit metadata |
### GitHub
| Command | Description |
|---------|-------------|
| `github_device_flow_start` | Begin OAuth device flow |
| `github_device_flow_poll` | Poll for authorization |
| `github_get_user` | Get authenticated user info |
| `github_list_repos` | List user's repos |
| `github_create_repo` | Create new repo |
| `clone_repo` | Clone repo with token auth |
| `clone_repo` | Clone a remote repository into a local folder using system git |
### Search
@@ -653,6 +666,8 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
| `check_claude_cli` | Check if Claude CLI is available |
| `get_ai_agents_status` | Check Claude Code + Codex availability |
| `stream_ai_agent` | Stream the selected CLI agent through the normalized event layer |
| `register_mcp_tools` | Register MCP in Claude/Cursor config |
| `check_mcp_status` | Check MCP registration state |
@@ -671,6 +686,10 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `save_image` | Save base64 image to vault |
| `copy_image_to_vault` | Copy image file to vault |
| `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions |
| `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA |
| `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes |
`get_build_number` feeds the bottom status bar label. It preserves legacy `bNNN` date-build labels, renders local `0.1.0` / `0.0.0` builds as `dev`, formats calendar alpha builds as `Alpha YYYY.M.D.N`, strips any calendar `-stable.N` suffix back to `YYYY.M.D`, and keeps legacy semver releases readable instead of falling back to `?`.
## Mock Layer
@@ -695,17 +714,19 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
| `useNoteActions` | `tabs`, `activeTabPath` | Composes `useNoteCreation` + `useNoteRename` + `frontmatterOps` |
| `useNoteCreation` | — | Note/type/daily-note creation with optimistic persistence |
| `useNoteCreation` | — | Note/type creation with optimistic persistence |
| `useNoteRename` | — | Note renaming with wikilink update |
| `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch |
| `useTabManagement` | Navigation history, note switching | Note navigation lifecycle |
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
| `useTheme` | Editor theme CSS vars | Editor typography theme |
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
| `useCliAiAgent` | `messages`, `status`, tool actions | Selected AI agent conversation |
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
| `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI |
| `useUnifiedSearch` | Query, results, loading state | Keyword search |
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
| `useSettings` | App settings (telemetry, release channel, auto-sync interval) | Persistent settings |
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
| `appCommandDispatcher` | Canonical shortcut/menu command IDs | Shared execution path for renderer and native menu commands |
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
@@ -725,6 +746,18 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
Selection-dependent note actions are wired through both the command palette and the native Note menu. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled.
Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
- native menu-command proof through `trigger_menu_command`
- The browser harness is only a deterministic desktop command bridge; exact native accelerator delivery still requires real Tauri QA for commands flagged as manual-native-critical
## Auto-Release & In-App Updates
### Release Pipeline
@@ -733,24 +766,47 @@ Every push to `main` triggers `.github/workflows/release.yml`:
```
push to main
→ version job: compute 0.YYYYMMDD.RUN_NUMBER
→ build job (matrix: aarch64 + x86_64):
pnpm install, stamp version, pnpm build, tauri build --target <arch>
→ upload .app, .tar.gz + .sig, .dmg as artifacts
→ version job: compute calendar alpha version YYYY.M.D-alpha.N
→ use today's UTC date unless the latest stable-vYYYY.M.D tag already uses today
if stable already uses today, advance alpha to the next calendar day so semver still increases
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin --bundles app
→ upload signed .app.tar.gz + .sig updater artifacts
→ release job:
download both arch artifacts
lipo aarch64 + x86_64 → universal binary
→ create universal .dmg + signed updater tarball
→ generate latest.json (per-arch + universal platform entries)
→ publish GitHub Release with all assets + auto-generated notes
generate alpha-latest.json
publish GitHub prerelease alpha-v<version> named Tolaria Alpha YYYY.M.D.N
→ pages job:
→ build static HTML release history page
→ publish alpha/latest.json
→ refresh latest.json + latest-canary.json as compatibility aliases to alpha
→ preserve stable/latest.json
→ deploy to gh-pages
```
Stable promotions trigger `.github/workflows/release-stable.yml`:
```
push stable-vYYYY.M.D tag
→ version job: validate YYYY.M.D from the tag
→ build job:
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
→ upload signed .app.tar.gz + .sig and .dmg artifacts
→ release job:
→ generate stable-latest.json
→ publish GitHub release Tolaria YYYY.M.D
→ pages job:
→ publish stable/latest.json
→ preserve alpha/latest.json
→ deploy to gh-pages
```
### Versioning
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). Stamped into `tauri.conf.json` and `Cargo.toml` dynamically.
- Stable promotions use git tags in the form `stable-vYYYY.M.D` and stamp the technical version `YYYY.M.D`.
- Alpha builds stamp the technical version `YYYY.M.D-alpha.N` and display it as `Alpha YYYY.M.D.N`.
- If the latest stable tag already uses today's date, alpha advances to the next calendar day before assigning `-alpha.N` so Alpha remains semver-newer than Stable across channel switches.
- The workflows stamp the computed version into `tauri.conf.json` and `Cargo.toml` at build time.
- This keeps display strings clean while preserving semver monotonicity when a user switches between Stable and Alpha.
### In-App Updates
@@ -807,19 +863,20 @@ sequenceDiagram
### Updates
Laputa uses the Tauri updater plugin for automatic updates:
Tolaria uses the Tauri updater plugin for automatic updates:
- Builds from `main` branch are published as GitHub Releases
- `latest.json` is published to GitHub Pages for the updater plugin
- `useUpdater()` hook checks for updates automatically and supports download + install
- `src-tauri/tauri.conf.json` points the default desktop feed at `stable/latest.json`
- `useUpdater(releaseChannel)` waits 3 seconds after launch, then calls Rust commands instead of hard-coding one updater endpoint in the frontend
- `src-tauri/src/app_updater.rs` maps the selected channel to `alpha/latest.json` or `stable/latest.json`
- `download_and_install_app_update` streams progress events back into `UpdateBanner`
### Feature Flags (PostHog + Release Channels)
Feature flags are backed by PostHog and evaluated per release channel:
- **Alpha**: all features always enabled (no PostHog lookup)
- **Beta**: sees features where the PostHog flag targets `release_channel = beta`
- **Stable** (default): sees features where the flag targets `release_channel = stable`
- **Stable** (default): PostHog rules decide which features are enabled
- **Beta cohorts**: modeled in PostHog as tags or person-property targeting, not as a separate updater build or Settings option
```typescript
import { useFeatureFlag } from './hooks/useFeatureFlag'
@@ -829,14 +886,14 @@ const enabled = useFeatureFlag('example_flag') // boolean
**Resolution order:**
1. `localStorage` override: key `ff_<name>` with value `"true"` or `"false"`
2. `isFeatureEnabled(flag)` in `telemetry.ts`checks release channel, then PostHog, then hardcoded defaults
2. `isFeatureEnabled(flag)` in `telemetry.ts`Alpha short-circuit, then PostHog, then hardcoded defaults
**How to add a new flag:**
1. Add the flag name to the `FeatureFlagName` union type in `src/hooks/useFeatureFlag.ts`
2. Create the flag on PostHog dashboard with rollout rules per channel
2. Create the flag on PostHog with Stable rollout rules and any optional beta-cohort targeting
3. Use `useFeatureFlag('your_flag')` in components
Release channel is selectable in Settings (alpha / beta / stable) and passed to PostHog as a person property via `identify()`. See ADR-0042.
Release channel is selectable in Settings as `alpha` or `stable` and passed to PostHog as a person property via `identify()`. Beta targeting is managed in PostHog, not in the updater settings. See ADR-0057.
## Platform Support — iOS / iPadOS (Prototype)
@@ -845,7 +902,7 @@ Tauri v2 supports iOS as a beta target. The Rust backend cross-compiles to `aarc
**Conditional compilation strategy:**
```
#[cfg(desktop)] — git CLI, menu bar, MCP server, Claude CLI, updater
#[cfg(desktop)] — git CLI, menu bar, MCP server, CLI AI agents, updater
#[cfg(mobile)] — stub commands returning graceful errors or empty results
```
@@ -854,8 +911,8 @@ Desktop-only modules gated at the crate level:
Desktop-only features gated at the function level in `commands/`:
- Git operations (commit, pull, push, status, history, diff, conflicts)
- GitHub operations (clone, list repos, device flow auth)
- Claude CLI streaming (check, chat, agent)
- Clone-by-URL via system git (`clone_repo`)
- CLI AI agent streaming (Claude, Codex)
- MCP registration and status
- Menu state updates

View File

@@ -31,7 +31,7 @@ pnpm playwright:regression # Full Playwright regression suite
## Directory Structure
```
laputa-app/
tolaria/
├── src/ # React frontend
│ ├── main.tsx # Entry point (renders <App />)
│ ├── App.tsx # Root component — orchestrates layout + state
@@ -54,18 +54,17 @@ laputa-app/
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
│ │ ├── AiPanel.tsx # AI agent (Claude CLI subprocess)
│ │ ├── AiPanel.tsx # AI agent panel (selected CLI agent)
│ │ ├── AiMessage.tsx # Agent message display
│ │ ├── AiActionCard.tsx # Agent tool action cards
│ │ ├── AiAgentsOnboardingPrompt.tsx # First-launch AI agent installer prompt
│ │ ├── SearchPanel.tsx # Search interface
│ │ ├── SettingsPanel.tsx # App settings
│ │ ├── StatusBar.tsx # Bottom bar: vault picker + sync
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
│ │ ├── WelcomeScreen.tsx # Onboarding screen
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
│ │ ├── TitleField.tsx # Editable note title above editor
│ │ ├── CloneVaultModal.tsx # Clone a vault from any git URL
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
│ │ ├── CommitDialog.tsx # Git commit modal
│ │ ├── CreateNoteDialog.tsx # New note modal
@@ -84,9 +83,12 @@ laputa-app/
│ │ ├── useVaultSwitcher.ts # Multi-vault management
│ │ ├── useVaultConfig.ts # Per-vault UI settings
│ │ ├── useNoteActions.ts # Composes creation + rename + frontmatter
│ │ ├── useNoteCreation.ts # Note/type/daily-note creation
│ │ ├── useNoteCreation.ts # Note/type creation
│ │ ├── useNoteRename.ts # Note renaming + wikilink updates
│ │ ├── useAiAgent.ts # AI agent state + tool tracking
│ │ ├── useAiAgent.ts # Legacy Claude-specific stream helpers reused by the shared agent hook
│ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized tool tracking
│ │ ├── useAiAgentsStatus.ts # Claude/Codex availability polling
│ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling
│ │ ├── useAiActivity.ts # MCP UI bridge listener
│ │ ├── useAutoSync.ts # Auto git pull/push
│ │ ├── useConflictResolver.ts # Git conflict handling
@@ -97,7 +99,10 @@ laputa-app/
│ │ ├── useCommandRegistry.ts # Command palette registry
│ │ ├── useAppCommands.ts # App-level commands
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
│ │ ├── appCommandCatalog.ts # Shortcut combos + command metadata
│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch
│ │ ├── useSettings.ts # App settings
│ │ ├── useGettingStartedClone.ts # Shared Getting Started clone action
│ │ ├── useOnboarding.ts # First-launch flow
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
│ │ ├── useMcpBridge.ts # MCP WebSocket client
@@ -121,6 +126,9 @@ laputa-app/
│ │ └── ...
│ │
│ ├── lib/
│ │ ├── aiAgents.ts # Shared agent registry + status helpers
│ │ ├── appUpdater.ts # Frontend wrapper around channel-aware updater commands
│ │ ├── releaseChannel.ts # Alpha/stable normalization helpers
│ │ └── utils.ts # Tailwind merge + cn() helper
│ │
│ └── test/
@@ -133,7 +141,7 @@ laputa-app/
│ ├── capabilities/ # Tauri v2 security capabilities
│ ├── src/
│ │ ├── main.rs # Entry point (calls lib::run())
│ │ ├── lib.rs # Tauri setup + command registration (61 commands)
│ │ ├── lib.rs # Tauri setup + command registration
│ │ ├── commands/ # Tauri command handlers (split into modules)
│ │ ├── vault/ # Vault module
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
@@ -146,14 +154,14 @@ laputa-app/
│ │ ├── frontmatter/ # Frontmatter module
│ │ │ ├── mod.rs, yaml.rs, ops.rs
│ │ ├── git/ # Git module
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs, clone.rs
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
│ │ ├── github/ # GitHub module
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs
│ │ ├── telemetry.rs # Sentry init + path scrubber
│ │ ├── search.rs # Keyword search (walkdir-based)
│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters
│ │ ├── claude_cli.rs # Claude CLI subprocess management
│ │ ├── mcp.rs # MCP server lifecycle + registration
│ │ ├── app_updater.rs # Alpha/stable updater endpoint selection
│ │ ├── settings.rs # App settings persistence
│ │ ├── vault_config.rs # Per-vault UI config
│ │ ├── vault_list.rs # Vault list persistence
@@ -179,7 +187,7 @@ laputa-app/
├── playwright.config.ts # Full Playwright regression config
├── playwright.smoke.config.ts # Curated pre-push Playwright config
├── ui-design.pen # Master design file
├── AGENTS.md # Shared project instructions for coding agents
├── AGENTS.md # Canonical shared instructions for coding agents
├── CLAUDE.md # Claude Code compatibility shim importing AGENTS.md
└── docs/ # This documentation
```
@@ -201,7 +209,8 @@ laputa-app/
|------|---------------|
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. |
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and restoring the cloned Getting Started vault. |
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and persisting cloned vaults in the switcher list. |
| `src/hooks/useGettingStartedClone.ts` | Shared "Clone Getting Started Vault" action for the status bar and command palette. |
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
### Backend
@@ -211,17 +220,21 @@ laputa-app/
| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. |
| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. |
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
| `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse). |
| `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. |
| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, Codex adapter, and stream normalization. |
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
| `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. |
### Editor
| File | Why it matters |
|------|---------------|
| `src/components/Editor.tsx` | BlockNote setup, breadcrumb bar, diff/raw toggle. |
| `src/components/SingleEditorView.tsx` | Shared BlockNote shell, Tolaria formatting controllers, and suggestion menus. |
| `src/components/editorSchema.tsx` | Custom wikilink inline content type definition. |
| `src/components/tolariaEditorFormatting.tsx` | Markdown-safe formatting toolbar surface for BlockNote. |
| `src/components/tolariaEditorFormattingConfig.ts` | Filters toolbar and slash-menu commands to markdown-roundtrippable actions. |
| `src/utils/wikilinks.ts` | Wikilink preprocessing pipeline (markdown ↔ BlockNote). |
| `src/components/RawEditorView.tsx` | CodeMirror 6 raw markdown editor. |
@@ -229,8 +242,9 @@ laputa-app/
| File | Why it matters |
|------|---------------|
| `src/components/AiPanel.tsx` | AI agent panel — Claude CLI with tool execution, reasoning, actions. |
| `src/hooks/useAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
| `src/components/AiPanel.tsx` | AI agent panel — selected CLI agent with tool execution, reasoning, and actions. |
| `src/hooks/useCliAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
| `src/lib/aiAgents.ts` | Supported agent definitions, status normalization, and default-agent helpers. |
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
### Styling
@@ -244,9 +258,13 @@ laputa-app/
| File | Why it matters |
|------|---------------|
| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). |
| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, auto-sync interval, default AI agent). |
| `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). |
| `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. |
| `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. |
| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow). |
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection and the vault-level explicit organization toggle. |
| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, default AI agent, and the vault-level explicit organization toggle. |
| `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. |
## Architecture Patterns
@@ -280,10 +298,17 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
- `window.__laputaTest.triggerMenuCommand()` for deterministic native menu-command coverage
That browser harness is a deterministic desktop command bridge, not real native accelerator QA. For macOS browser-reserved chords, still perform native QA in the real Tauri app because the webview-init prevent-default layer is only active there. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works unless you also confirm the visible app behavior.
## Running Tests
```bash
@@ -337,7 +362,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. Register the command in `useAppCommands.ts` via the command registry
2. Add a corresponding menu bar item in `menu.rs` for discoverability
3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts`
3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID, modifier rule, and deterministic QA mode, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar
4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly
### Modify styling
@@ -351,3 +376,4 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
5. **Shared agent adapters / Codex args**: Edit `src-tauri/src/ai_agents.rs`

View File

@@ -1,4 +1,4 @@
# Laputa — Product Vision
# Tolaria — Product Vision
*Written by Brian based on conversations with Luca Rossi, FebMar 2026.*
*This is a living document — update it as the vision evolves.*
@@ -13,21 +13,21 @@ The best projects are built by people who have an unusually strong answer to "wh
**Luca Rossi** is a startup founder and former generalist CTO — someone who can build a product end-to-end across code, design, scope, and product. And for the last five years, full-time, he has run Refactoring: a technical newsletter with nearly 200,000 subscribers, for which he has written over 300 original articles. In word count, that's roughly two *Lord of the Rings* novels.
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Laputa is an attempt to bottle that system.
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Tolaria is an attempt to bottle that system.
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Laputa is not theorized — it's been battle-tested for years at scale.
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Tolaria is not theorized — it's been battle-tested for years at scale.
**The distribution is built in.** Refactoring reaches ~200,000 engineers, managers, and technical leaders — exactly the people most receptive to a tool like this. The audience already trusts the author on this topic, because they've been reading his writing about knowledge management and learning for years.
This is not a product looking for a market. It's a tool built by its first power user, for an audience that already knows and trusts him.
**Why Laputa, in the context of Refactoring.**
**Why Tolaria, in the context of Refactoring.**
Refactoring is a newsletter about how software is built, how teams work, and how digital products are developed — written from Luca's experience and conversations with other tech leaders. A natural question follows: what is the author's own current experience building software with AI?
Laputa answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
Tolaria answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
This is why Laputa is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
This is why Tolaria is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
The strategy is coherent: build the tool you describe, make the work visible, let the product speak for the author.
@@ -45,7 +45,7 @@ The problem has two distinct layers:
2. **Methodological**: even with the right tool, most people don't know *how* to organize knowledge so it becomes useful over time — what to capture, how to connect things, how to turn raw notes into a system that works with you instead of against you.
Laputa addresses both layers, together. That's what makes it different.
Tolaria addresses both layers, together. That's what makes it different.
---
@@ -53,11 +53,11 @@ Laputa addresses both layers, together. That's what makes it different.
Most PKM tools give you a blank canvas and leave the rest to you. They solve the first problem (somewhere to put things) but not the second (how to organize them). The result is that sophisticated users build complex custom systems, while everyone else gives up.
Laputa's position is different: **we ship the method alongside the tool.**
Tolaria's position is different: **we ship the method alongside the tool.**
The method is opinionated but not rigid. It tells you: here's how to think about your work, here's where different kinds of notes belong, here's how to connect them. If it fits your needs — great, start immediately. If your situation is different — customize it. The types, the relationships, the structure can all be changed. But you don't have to figure it out from scratch.
This combination — an opinionated method on top of a technically excellent foundation — is what makes Laputa genuinely useful to people who are stuck, not just people who already know what they're doing.
This combination — an opinionated method on top of a technically excellent foundation — is what makes Tolaria genuinely useful to people who are stuck, not just people who already know what they're doing.
---
@@ -65,7 +65,7 @@ This combination — an opinionated method on top of a technically excellent fou
### The knowledge ontology
Laputa organizes work around two axes:
Tolaria organizes work around two axes:
| | **One-time** | **Recurring** |
|---|---|---|
@@ -84,7 +84,7 @@ This ontology is not arbitrary. It maps cleanly to how both individuals and orga
### Knowledge has a purpose
A principle that underlies everything in Laputa: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
A principle that underlies everything in Tolaria: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
This is the difference between a knowledge system that works over years and one that collapses after a few weeks. Without a real purpose, the maintenance cost of taking notes is never justified, and people stop. With a purpose — writing regularly, building things, making decisions — the system pays for itself.
@@ -120,7 +120,7 @@ This is convention *over* configuration — not convention *instead of* it.
## The foundation: architecture that earns trust
The method is only as good as the system it runs on. Laputa's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
The method is only as good as the system it runs on. Tolaria's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
### Local files, version-controlled with Git
@@ -134,11 +134,11 @@ A vault of plain Markdown files, version-controlled with Git, is dramatically mo
An AI agent working on a local vault can read thousands of notes in seconds, understand their structure, write new ones, connect existing ones, and commit the changes back — all with full comprehension. Notion's AI can't do this. No SaaS-based AI can do this, because the architecture doesn't allow it.
More importantly: the more a vault follows Laputa's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
More importantly: the more a vault follows Tolaria's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
### Open and exit-friendly
The trust between Laputa and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
The trust between Tolaria and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
---
@@ -147,9 +147,9 @@ The trust between Laputa and the user is earned daily, not enforced by format. I
Obsidian is the obvious comparison. The difference is philosophy:
- **Obsidian** is a blank canvas. Infinitely configurable via plugins and community extensions. Powerful for users who want to build their own system — and who have the time and patience to do so.
- **Laputa** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
- **Tolaria** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Laputa, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Tolaria, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
---
@@ -157,7 +157,7 @@ Obsidian also treats Git as an afterthought — its business model is built arou
### Three stages of adoption
Laputa is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
Tolaria is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
**Stage 1: Personal PKM + AI context** *(current)*
A single person manages their knowledge, life, and work in a local vault. The primary collaborator is AI. The vault gives structure to one person's context, making it legible to an AI that can assist meaningfully across all areas of work and life. The method helps structure the knowledge; the AI helps use it.
@@ -172,7 +172,7 @@ The ontology scales to organizations. Companies have projects, responsibilities,
### The right early adopters
The first users who will get the most from Laputa are technically-minded individuals who:
The first users who will get the most from Tolaria are technically-minded individuals who:
- Are frustrated with Notion's performance, complexity, or lock-in
- Understand or are comfortable with Git
- Want a system that's AI-native by design, not by bolted-on features

View File

@@ -2,8 +2,9 @@
type: ADR
id: "0018"
title: "CodeScene code health gates in CI and git hooks"
status: active
status: superseded
date: 2026-03-13
superseded_by: "0064"
---
## Context

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0050"
title: "Deterministic shortcut command routing"
status: active
date: 2026-04-11
---
## Context
Laputa is keyboard-first, but shortcut execution had split ownership: `useAppKeyboard` handled some shortcuts in the renderer while `menu.rs` owned others as native Tauri menu accelerators. That split made QA unreliable. Browser tests could prove the renderer path, but not the native menu path, and flaky macOS key synthesis made `Cmd+Shift+L`, `Cmd+Shift+I`, and `Cmd+N` regressions easy to miss.
## Decision
**Keyboard shortcuts and native menu accelerators now dispatch through the same canonical app command IDs. Renderer-owned shortcuts call the shared dispatcher directly; native menu items emit the same IDs into the frontend, and tests get a deterministic menu-command trigger that exercises that route without relying on synthesized native keystrokes.**
## Options considered
- **Option A** (chosen): Shared command IDs plus deterministic menu-command trigger — keeps native desktop UX while making menu-owned commands testable in unit tests, Playwright, and native QA. Downside: one more command layer to maintain.
- **Option B**: Move every shortcut to the renderer — simpler automated testing, but worse macOS menu-bar parity and weaker native UX.
- **Option C**: Keep renderer and native shortcuts separate — lowest code churn, but continues to produce false confidence and shortcut regressions.
## Consequences
- `appCommandDispatcher.ts` owns the canonical shortcut command IDs and the shared execution path used by `useAppKeyboard` and `useMenuEvents`.
- Native menu routing remains explicit in `menu.rs`; adding or changing a native shortcut now requires wiring the accelerator and the matching command ID in one place.
- Automated QA can trigger menu-owned commands deterministically through the shared `window.__laputaTest.triggerMenuCommand()` bridge in browser runs and through the native `trigger_menu_command` Tauri command in desktop runs.
- Keyboard QA should prefer real menu selection or the deterministic menu-command trigger for native-owned shortcuts, and reserve synthesized keystrokes for renderer-owned shortcuts or true end-to-end spot checks.
- This decision supersedes the blanket assumption in ADR 0020 that all shortcut verification can be treated as plain keyboard-event testing.

View File

@@ -0,0 +1,31 @@
---
type: ADR
id: "0051"
title: "Shared shortcut manifest for testable routing"
status: active
date: 2026-04-11
---
## Context
ADR 0050 moved renderer shortcuts and native menu events onto the same command dispatcher, but shortcut ownership still drifted across multiple places: `appKeyboardShortcuts.ts`, `appCommandDispatcher.ts`, command-palette metadata, and `menu.rs`. That made shortcut regressions easy to reintroduce because the same facts had to be updated manually in several files.
The riskiest failures were exactly the native-owned commands that matter most in a keyboard-first app: `Cmd+\` for raw editor, `Cmd+Shift+I` for properties, and `Cmd+Shift+L` for the AI panel. We need one declarative place that says which command owns which shortcut, whether the shortcut is renderer-owned or native-menu-owned, and how tests should trigger it.
## Decision
**Shortcut-capable app commands are now defined in a shared frontend manifest that owns command IDs, routing semantics, and shortcut ownership. Renderer keyboard handling resolves commands from that manifest, native menu routing dispatches the same command IDs, and deterministic QA for native-owned shortcuts targets those IDs rather than duplicating shortcut facts in ad hoc code paths.**
## Options considered
- **Option A** (chosen): Shared shortcut manifest plus shared dispatcher and deterministic menu-command QA. This reduces drift, improves CodeScene on the command router, and makes native-owned shortcuts provable without flaky macOS key synthesis. Downside: one more manifest to maintain.
- **Option B**: Keep the shared dispatcher from ADR 0050 but continue storing shortcut ownership in separate key maps and menu lists. Lower churn, but it keeps the exact source of the regressions we reopened.
- **Option C**: Move all shortcuts into renderer-only handlers. Easier to test, but weaker macOS menu-bar parity and worse native desktop UX.
## Consequences
- `appCommandCatalog.ts` is now the frontend source of truth for shortcut-capable command IDs, ownership, modifier rules, and dispatch kind.
- `appCommandDispatcher.ts` is reduced to route execution instead of carrying a large switch plus duplicated ownership metadata.
- `useAppKeyboard.ts` resolves shortcuts from the shared manifest, including the distinction between `Cmd+Shift+L` (macOS-only) and `CmdOrCtrl+Shift+I/F/O`.
- Native-menu smoke tests should use `window.__laputaTest.triggerMenuCommand()` or the Tauri `trigger_menu_command` bridge to prove the native command path. Renderer-only commands may still be proven with direct keyboard events.
- This ADR supersedes ADR 0050 by replacing “shared command IDs are enough” with “shared command IDs plus shared shortcut ownership metadata are required.”

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0052"
title: "Renderer-first shortcut execution with native-menu dedupe"
status: active
date: 2026-04-11
---
## Context
ADR 0051 gave Laputa a shared shortcut manifest and shared command IDs, but it still treated many shortcuts as native-menu-owned at execution time. In practice that meant `useAppKeyboard` deferred commands like `Cmd+Shift+I`, `Cmd+Shift+L`, and `Cmd+\` whenever the app ran under Tauri, and automated QA had to prove those flows by injecting menu-command IDs instead of pressing the real keys.
That is not a strong enough QA story for a keyboard-first app. If a user presses a shortcut while the editor is focused, we need a deterministic way to prove the actual key combo works. At the same time, we still want a native macOS menu bar with working menu items and accelerators.
## Decision
**Renderer keyboard handling is now the primary execution path for all shortcut-capable app commands, including commands that also have native menu accelerators. Native menu clicks and accelerators still emit the same command IDs, but the shared dispatcher suppresses the duplicate native/renderer echo from a single keypress so the command runs exactly once.**
## Options considered
- **Option A** (chosen): Renderer-first shortcut execution plus native-menu dedupe. This keeps shortcuts testable with real key events in a Tauri-like environment while preserving menu-bar parity and clickable native menu items. Downside: the dispatcher has to understand and suppress paired native/renderer echoes.
- **Option B**: Keep deferring native-owned shortcuts out of the renderer and prove them only through `trigger_menu_command`. Lower implementation churn, but it still leaves the real keystroke path unproven.
- **Option C**: Remove native accelerators entirely and keep shortcuts renderer-only. Simplest to reason about, but weaker desktop UX and poorer macOS menu discoverability.
## Consequences
- `appCommandCatalog.ts` remains the single manifest for command IDs and shortcut combos, but keyboard execution no longer depends on a separate owner flag.
- `useAppKeyboard` handles the actual key event for every shortcut-capable command, even in Tauri mode.
- `useMenuEvents` still handles menu clicks and test-triggered native command IDs, but shared dispatcher dedupe prevents a focused keypress from firing twice when the native menu accelerator also echoes back into the renderer.
- Deterministic QA now has two complementary proofs:
- real keyboard events in a Tauri-like environment for the actual shortcut combo
- `trigger_menu_command` for the native menu click/accelerator command path
- This ADR supersedes ADR 0051 by replacing “execution ownership lives in the manifest” with “shortcut combos live in the manifest, while execution is renderer-first and native menu dispatch is deduped.”

View File

@@ -0,0 +1,30 @@
---
type: ADR
id: "0053"
title: "Webview-init prevention for browser-reserved shortcuts"
status: active
date: 2026-04-11
---
## Context
ADR 0052 made renderer-first shortcut handling the primary path for command execution, with native menu accelerators deduped afterward. That works for normal shortcuts, but native QA on macOS showed that `Cmd+Shift+L` still failed to reach the app even though the shared command path and the Note menu item both worked.
The gap is WKWebView itself: some browser-reserved chords are swallowed by the webview before the renderer-level shortcut listener can execute. That makes the shortcut untestable with the real native keypress even though the command bus is correct.
## Decision
**Laputa will keep renderer-first shortcut execution, but for macOS browser-reserved chords we will add a narrow Tauri webview-init prevention layer using `tauri-plugin-prevent-default` so the real keystroke reaches the shared command path.**
## Options considered
- **Option A** (chosen): Add a narrow `tauri-plugin-prevent-default` registration for only the known browser-reserved chords we actually use. This preserves ADR 0052, keeps the command bus unified, and fixes the real native keystroke path without broad shortcut capture.
- **Option B**: Keep relying on renderer capture listeners alone. Simpler, but it fails for chords that WKWebView consumes before renderer code sees them.
- **Option C**: Use a global shortcut plugin as the fallback path. This would catch the keystroke natively, but it reserves the chord outside Laputa and is too heavy for app-local shortcuts.
## Consequences
- Shortcut ownership stays unified: command IDs and execution still live in the shared renderer/native command bus.
- macOS-only browser-reserved chords now have one extra declaration point in `src-tauri/src/lib.rs`, and that list must stay intentionally small.
- Native QA remains mandatory for any shortcut added to that list, because browser dev and mocked Tauri tests do not exercise the webview-init layer.
- Re-evaluate this decision if Tauri/WKWebView exposes a better app-local native shortcut hook that does not require browser-reserved-key workarounds.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0054"
title: "Deterministic shortcut QA matrix"
status: active
date: 2026-04-11
---
## Context
ADR 0052 made renderer-first shortcut execution the primary runtime path, and ADR 0053 added a narrow macOS webview-init prevent-default layer for browser-reserved chords such as `Cmd+Shift+L`. Those decisions improved behavior, but the automated QA story was still muddy:
- browser smoke tests were describing a mocked desktop harness as if it were native Tauri QA
- some tests used `page.keyboard.press()` for commands whose real desktop accelerators are intercepted or reserved by the browser shell
- native menu command coverage existed, but the catalog did not declare which deterministic proof path each shortcut should use
That made it too easy to ship a shortcut with passing automation while overstating what the automation had actually proven.
## Decision
**Laputa will treat shortcut QA as an explicit part of the shared command manifest. Every shortcut-capable command must have a deterministic automated proof path, and the test harness must distinguish renderer shortcut-event proof from native menu-command proof instead of calling the browser harness “native Tauri QA”.**
## Options considered
- **Option A** (chosen): Add a deterministic shortcut QA matrix to the shared command catalog. Renderer shortcut handling can be exercised through synthetic `keydown` events generated from the manifest, while native menu commands are exercised through `trigger_menu_command`. Pros: deterministic, explicit, and honest about what is being proved. Cons: still requires real native QA for exact accelerator delivery on macOS.
- **Option B**: Keep using ad hoc Playwright key presses and browser-side menu shims. Lower change cost, but still allows false claims about native coverage and still depends on browser-reserved shortcuts behaving nicely.
- **Option C**: Block all shortcut work until full native Tauri automation exists. Strongest eventual guarantee, but it would leave the keyboard-first app without a usable deterministic QA strategy today.
## Consequences
- `appCommandCatalog.ts` now owns not just command IDs and modifier rules, but also the deterministic QA mode for each shortcut-capable command.
- Browser harness smoke tests must describe themselves as a desktop command bridge, not native app QA.
- Renderer shortcut behavior can be verified deterministically without depending on browser chrome or flaky AppleScript key synthesis.
- Native menu-command behavior can be verified deterministically through the Tauri command bridge.
- Exact desktop accelerator delivery still requires real Tauri QA for commands flagged as needing manual native verification, especially browser-reserved macOS chords.

View File

@@ -0,0 +1,42 @@
---
type: ADR
id: "0055"
title: "H1 is the only editor title surface"
status: active
date: 2026-04-11
supersedes: "0044"
---
## Context
ADR-0044 moved Laputa to H1-as-title, but the frontend still carried a legacy fallback: when a note had no H1, `TitleField` and the old title section could reappear above the editor. That left two competing title surfaces in the product and made it possible for deleting an H1 to resurrect UI that was supposed to be gone.
The result was both behavioral drift and stale tests: some code paths still treated the dedicated title row as a valid editing surface even though the product direction is now keyboard-first writing directly in the document body.
## Decision
**The editor body is now the only title surface. Laputa never renders a separate title section above the editor, regardless of whether a note currently has an H1.**
Display-title behavior stays:
1. First H1 in the body
2. Legacy frontmatter `title:`
3. Filename-derived fallback
But the UI no longer exposes a dedicated title field for cases 2 or 3. When a note has no H1, the editor simply shows normal body content or the empty-editor placeholder.
Filename operations remain explicit:
- untitled notes still auto-rename from H1 on save
- manual filename rename/sync remains in the breadcrumb
## Options considered
- **Option A** (chosen): remove the fallback title section entirely. This makes the editor honest, removes a stale code path, and keeps title editing aligned with the keyboard-first document model.
- **Option B**: keep the fallback title field for non-H1 notes. This preserves an alternate rename path, but it reintroduces the exact dual-surface ambiguity that ADR-0044 tried to escape.
- **Option C**: hide the title section with CSS only. Low churn, but it leaves dead render/state paths in place and makes regressions like “delete H1 and old title row returns” easy to reintroduce.
## Consequences
- Deleting an H1 no longer reveals any legacy title UI; the user stays in the editor body.
- `TitleField` and the title-section render path are removed from the frontend.
- Breadcrumb filename controls are now the only explicit file-identifier editing surface outside the editor body.
- Older tests that asserted title editing through `TitleField` are obsolete and should be replaced by H1-title or breadcrumb-filename coverage.

View File

@@ -0,0 +1,50 @@
---
type: ADR
id: "0056"
title: "System git auth only — no provider-specific OAuth or repo APIs"
status: active
date: 2026-04-12
supersedes: "0019"
---
## Context
Tolaria already uses the system `git` executable for the core remote workflow: commit, pull, push, status, history, and conflict resolution. The only provider-specific part left was GitHub authentication and repository management:
- GitHub Device Flow OAuth
- persisted `github_token` / `github_username` settings
- GitHub-only clone/create UI
- GitHub API calls for repo listing and creation
That split made the product more complex than the actual user need. Tolaria's remote-sync users are developers who typically already have git configured via SSH keys, Git Credential Manager, Keychain helpers, or `gh auth`. The app was carrying a provider-specific auth stack even though the real transport path was already plain git CLI.
## Decision
**Tolaria does not implement provider-specific authentication or remote-repository APIs. All remote auth is delegated to the user's existing system git configuration, and cloning is a generic "paste any git URL" flow.**
Concretely:
- remove GitHub Device Flow commands and UI
- remove persisted GitHub auth fields from app settings
- remove GitHub repo list/create API integration
- keep `clone_repo`, but make it a generic system-git clone command
- keep commit / pull / push behavior unchanged apart from surfacing raw git errors directly
## Options considered
- **Option A — Keep GitHub Device Flow OAuth** (ADR-0019, now superseded): polished GitHub-specific onboarding, but it preserves provider lock-in, token storage, and an entire second auth model beside system git.
- **Option B — Replace OAuth with manual PAT entry**: smaller implementation than Device Flow, but still provider-specific, still stores credentials in app settings, and still teaches users the wrong abstraction.
- **Option C — Pure system git auth** (chosen): one auth path, less code, works with any git host, and aligns the clone flow with the rest of Tolaria's git stack. Downside: users must already have git auth configured outside the app.
## Consequences
- `CloneVaultModal` accepts any git URL and local destination path.
- `clone_repo` shells out to system git without injecting provider tokens.
- `git_push` / `git_pull` continue to rely on the same external git configuration; auth failures surface as raw git stderr.
- `SettingsPanel` no longer contains a GitHub connection section.
- Tolaria no longer stores git-provider credentials in `settings.json`.
- GitHub, GitLab, Bitbucket, Gitea, and self-hosted remotes all work through the same product path.
- Creating or listing remote repos from inside Tolaria is no longer supported; remote setup happens in the user's normal git tools.
- The Getting Started vault still clones from a public remote URL, but it now goes through the same generic git clone path as every other vault import.
Re-evaluate if Tolaria later targets less technical users who cannot reasonably be expected to configure git outside the app.

View File

@@ -0,0 +1,44 @@
---
type: ADR
id: "0057"
title: "Alpha/stable release channels with PostHog beta cohorts"
status: active
date: 2026-04-12
---
## Context
Tolaria's updater and release docs still described a canary branch, a beta updater channel, and a single `latest.json` feed. That no longer matched the desired product model:
- `main` should continuously publish **alpha** builds.
- **Stable** should be promoted manually by pushing `stable-vX.Y.Z` tags.
- "Beta" users should be modeled in PostHog for targeting and analysis, not as a separate binary or updater feed.
The updater also needed semver-safe versioning when a user switches between Stable and Alpha. A date-based alpha version below the latest stable release would cause the updater to ignore newer alpha builds after a stable promotion.
This ADR supersedes ADR-0017's canary-branch updater model.
## Decision
**Tolaria exposes exactly two updater channels: `stable` and `alpha`. Stable is the default feed, while every push to `main` publishes a prerelease alpha build to `alpha/latest.json`, and manually promoted `stable-vX.Y.Z` tags publish stable builds to `stable/latest.json`. Beta audiences are handled in PostHog and are not a third updater channel.**
## Options considered
- **Option A** (chosen): Two updater channels (`stable`, `alpha`) plus PostHog beta cohorts. Pros: matches the product requirement, keeps CI simple, keeps updater semantics understandable, and separates release distribution from experimentation audiences. Cons: requires semver-aware alpha versioning and a small migration for legacy channel settings.
- **Option B**: Keep the canary branch / canary channel model. Pros: no workflow redesign. Cons: no longer matches how releases are actually promoted and forces distribution strategy to depend on a long-lived branch.
- **Option C**: Add a third updater channel for beta builds. Pros: direct binary segmentation. Cons: extra CI complexity, extra updater endpoints, and unnecessary duplication because beta targeting is already better handled by PostHog.
## Consequences
- `release.yml` now publishes alpha prereleases from every push to `main`.
- `release-stable.yml` publishes stable releases only from `stable-v*` tags.
- `src-tauri/src/app_updater.rs` selects `alpha/latest.json` or `stable/latest.json` at runtime.
- `release_channel` stays an app setting, but only `alpha` is stored explicitly; Stable serializes to the default `null` value.
- Legacy or invalid persisted channel values fall back to Stable.
- Alpha versions are prereleases of the next stable patch version (for example `1.2.4-alpha.202604122135.7` after stable `1.2.3`) so semver ordering remains valid across channel switches.
- The legacy GitHub Pages aliases `latest.json` and `latest-canary.json` continue to mirror alpha for backward compatibility.
- Beta rollouts and internal-user targeting are done in PostHog using person properties or cohorts rather than updater manifests.
## Advice
If a future release process needs more than two binary distribution rings, re-evaluate this decision only when PostHog cohorting is no longer sufficient and the extra operational cost of another updater feed is justified.

View File

@@ -0,0 +1,36 @@
---
type: ADR
id: "0058"
title: "Claude Code first-launch onboarding gate"
status: superseded
superseded_by: "0062"
date: 2026-04-12
---
## Context
Tolaria's AI features depend on the `claude` CLI being installed on the user's machine. New users arriving with no prior context could open the app, try AI-powered workflows, and get silent failures with no explanation.
A dedicated first-launch prompt was needed to:
- Surface whether the `claude` CLI is already present.
- Guide users to the install page if it is missing.
- Not block experienced users who want to skip the check.
The existing `useOnboarding` hook already handles vault setup and resolves to a `ready` state, but it had no mechanism for a post-vault, pre-app step.
## Decision
**A one-time `ClaudeCodeOnboardingPrompt` is shown immediately after vault onboarding resolves to `ready`, before the main app shell renders. Dismissal is persisted in `localStorage` via `useClaudeCodeOnboarding`, so the gate appears exactly once per install.**
## Options considered
- **Option A** (chosen): Full-screen gate after vault onboarding, dismissed once and persisted in `localStorage`. Pros: cannot be missed on first launch, reuses `useClaudeCodeStatus` for live detection, zero impact on returning users. Cons: adds one extra render phase to the boot sequence.
- **Option B**: Inline banner inside the main app. Pros: less intrusive. Cons: easy to ignore, harder to surface install link prominently.
- **Option C**: Check at feature use time (show error when AI action fails). Pros: no new screen. Cons: poor UX — silent failure or cryptic error at the moment the user needs AI.
## Consequences
- The app boot sequence now has four phases: loading → welcome (if needed) → Claude Code check (once) → main shell.
- `useClaudeCodeOnboarding(enabled)` takes a boolean so the gate is skipped entirely in note windows and before vault onboarding completes.
- The dismissal key (`tolaria:claude-code-onboarding-dismissed`) must be pre-set in Playwright storage state so smoke tests bypass the gate.
- Re-evaluation warranted if the `claude` CLI gains an in-app auto-install path, making the manual prompt unnecessary.

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0059"
title: "Local-only git commits for vaults without a remote"
status: active
date: 2026-04-12
---
## Context
ADR-0034 mandates a git repo for every vault, but never required a remote. In practice, the commit flow always attempted a `git push` after staging and committing. Users with purely local vaults (no remote configured) would hit a push error on every commit.
The fix required distinguishing between two commit modes at the point of user action:
- **Push mode**: repo has a remote → commit then push (existing behavior).
- **Local mode**: repo has no remote → commit only, no push attempted.
## Decision
**`useCommitFlow` detects the vault's remote status before opening the commit dialog and at commit time. When `hasRemote === false`, it commits locally and skips the push step entirely, showing "Committed locally (no remote configured)" as the confirmation toast.**
## Options considered
- **Option A** (chosen): Runtime detection via a new `useGitRemoteStatus` hook + `CommitMode` type (`push` | `local`). Pros: transparent to the user, no configuration needed, adapts if a remote is added later. Cons: adds an async remote-status check to the commit open flow.
- **Option B**: Require all vaults to have a remote (keep blocking behavior). Pros: simpler model. Cons: breaks the valid use case of a local-only knowledge base; contradicts ADR-0056 which removed provider-specific OAuth.
- **Option C**: Let the push fail silently and always show success. Pros: no new logic. Cons: misleading feedback; users wouldn't know the push was skipped vs. succeeded.
## Consequences
- `useGitRemoteStatus` is a new hook that exposes `remoteStatus` and `refreshRemoteStatus`; it is called both when opening the commit dialog and after each commit.
- `CommitDialog` now receives a `commitMode` prop and adjusts its CTA label accordingly (`Commit & Push` vs `Commit`).
- The `commitAndPush` callback in `CommitFlowConfig` is replaced by `resolveRemoteStatus` + `vaultPath`; the actual git operations (`git_commit`, `git_push`) are invoked directly inside `useCommitFlow`.
- Local-only commits fire `trackEvent('commit_made')` the same as push commits for analytics continuity.
- Re-evaluation warranted if a remote is later added to a previously-local vault and the UX should prompt the user to push accumulated commits.

View File

@@ -0,0 +1,28 @@
---
type: ADR
id: "0060"
title: "Network-aware UI gating for remote-dependent features"
status: active
date: 2026-04-13
---
## Context
Some app features require an active internet connection (e.g., cloning the Getting Started vault template from GitHub). Prior to this decision, the UI would attempt the operation and surface a generic error only after failure. Users on first launch in offline environments got a confusing error when trying to use the template.
## Decision
**Introduce a `useNetworkStatus` hook that tracks `navigator.onLine` via `online`/`offline` DOM events, and use it to proactively gate UI surfaces that require a network.** Features that depend on remote access (clone, sync) show an explanatory message and disable their action button when the device is offline, rather than failing silently at execution time.
## Options considered
- **Option A** (chosen): `useNetworkStatus` hook + proactive UI disable — disables the action before the user tries it, with inline copy explaining the offline state.
- **Option B**: Attempt and catch — let the operation run and surface the error in a toast. Simpler, but poor UX for first-launch users who don't know what went wrong.
- **Option C**: Check connectivity with a ping on demand — more accurate but adds latency and complexity; `navigator.onLine` is sufficient for the use case.
## Consequences
- Positive: cleaner first-run experience for offline users; no misleading error messages.
- Positive: `useNetworkStatus` is a reusable hook for future remote-gated features.
- Negative: `navigator.onLine` can return `true` on a captive-portal / no-internet network — the hook reflects OS-level connectivity, not end-to-end reachability. The operation may still fail with a network error, which must still be handled.
- Re-evaluate if the app adds more remote features that need finer-grained reachability checks.

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0061"
title: "AI prompt bridge — module-level event bus for cross-component prompt routing"
status: active
date: 2026-04-13
---
## Context
The AI panel is a sibling subtree to the command palette in the component tree. When the user submits a prompt from the command palette's AI mode, the AI panel (mounted elsewhere) needs to receive it and start processing. Props-down / callbacks-up wiring between the two would require threading state through multiple layers of unrelated components.
## Decision
**Introduce `aiPromptBridge.ts` as a module-level singleton event bus.** The bridge exposes `queueAiPrompt(text, references)` (write path) and `takeQueuedAiPrompt()` (consume path), backed by a module variable and a `CustomEvent` on `window` (`tolaria:ai-prompt-queued`). The command palette enqueues a prompt; the AI panel listens for the event, consumes the prompt via `takeQueuedAiPrompt`, and dispatches it to the agent. A companion `requestOpenAiChat()` function fires a separate `tolaria:open-ai-chat` event to open the panel before the prompt is sent.
## Options considered
- **Option A** (chosen): module-level singleton + `window` events — zero dependencies, no new global state manager, consistent with the existing `window.dispatchEvent` pattern already used for menu-command bridging.
- **Option B**: Lift AI panel state to a shared ancestor (e.g., `App.tsx`) and pass `onPrompt` callback down — would require `App.tsx` to own AI agent state, bloating it further; conflicts with ADR-0026 (props-down principle).
- **Option C**: Zustand / Jotai global store atom — adds a dependency and architecture overhead for a narrow, two-participant channel.
## Consequences
- Positive: decouples command palette from AI panel with no shared ancestor coupling.
- Positive: any future surface (e.g., wikilink context menu, note action bar) can call `queueAiPrompt` without tree-level wiring.
- Negative: module-level mutable state is harder to test in isolation; tests must call `takeQueuedAiPrompt` to drain state between runs.
- Negative: the event is fire-and-forget — if the AI panel is not mounted when the event fires, the prompt is silently dropped (currently not an issue as the panel is always mounted).
- Re-evaluate if the number of AI entry points grows large enough to warrant a proper state management solution.

View File

@@ -0,0 +1,32 @@
---
type: ADR
id: "0062"
title: "Selectable CLI AI agents with a shared panel architecture"
status: active
date: 2026-04-13
---
## Context
Tolaria's AI panel, onboarding flow, and status surfaces were built around a single CLI dependency: Claude Code. That worked for the first release, but it made every UI and backend seam agent-specific. Adding Codex as a second supported CLI agent would have duplicated large parts of the app: separate availability checks, a second onboarding path, another status badge, and yet another streaming hook.
The product direction is broader than a single vendor. Tolaria needs one AI panel that can target multiple local CLI agents while preserving the same MCP-backed vault tooling, the same note-context assembly, and a single install-local preference for which agent should be used by default.
## Decision
**Introduce a shared CLI-agent abstraction for Tolaria's AI surfaces.** The frontend now treats agents as a small registry (`claude_code`, `codex`) with labels, install URLs, availability state, and a persisted `default_ai_agent` setting. The AI panel, onboarding gate, command palette, and status bar all read from that shared model. On the backend, `ai_agents.rs` owns agent detection and streaming, dispatching to per-agent adapters: Claude still flows through `claude_cli.rs`, while Codex is launched through `codex exec --json` with Tolaria's MCP server injected via transient config flags.
## Options considered
- **Option A** (chosen): shared agent registry + backend adapter layer — one panel, one preference, one onboarding path, and a clear place to add future CLI agents.
- **Option B**: keep the UI Claude-specific and bolt on Codex as a second special case — lowest short-term cost, but every new agent multiplies the number of bespoke checks, prompts, and command handlers.
- **Option C**: split the product into separate per-agent panels — clearer ownership per integration, but fragments the UX and makes command-palette / status-bar interactions inconsistent.
## Consequences
- Positive: new CLI agents can be added by implementing one backend adapter and registering one frontend definition.
- Positive: onboarding and settings now explain the AI capability of the app at the product level rather than assuming Claude Code is the only valid path.
- Positive: the default agent is installation-local, matching ADR-0004's rule that machine-specific tool preferences belong in app settings rather than the vault.
- Negative: event normalization is now Tolaria-owned; backend adapters must translate each CLI's stream format into a common event model.
- Negative: some user guidance becomes agent-specific again at the edge, such as install links and authentication errors (`claude` login vs `codex login`).
- Re-evaluate if one agent needs capabilities the shared panel cannot express cleanly, or if Tolaria ever moves from CLI subprocesses to a dedicated local SDK/runtime.

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0063"
title: "BlockNote code-block package for editor syntax highlighting"
status: active
date: 2026-04-13
---
## Context
Tolaria uses BlockNote for rich-text editing. Fenced code blocks already render on BlockNote's dark `pre > code` surface, but they were missing syntax highlighting and inherited the muted inline-code chip background from the global `code` selector in `EditorTheme.css`. The QA expectation is a dark code block with highlighted tokens and light code text, without regressing inline-code styling elsewhere in the editor.
BlockNote documents syntax highlighting as a schema concern: replace the default `codeBlock` spec with `createCodeBlockSpec(...)` and provide a Shiki highlighter. Tolaria also needs to preserve the existing default behavior for unlabeled code blocks, which should stay plain text instead of defaulting to JavaScript.
## Decision
**Tolaria overrides the default BlockNote `codeBlock` spec with `@blocknote/code-block`, keeps `defaultLanguage: "text"`, and scopes the muted inline-code chip styling away from fenced code blocks.**
## Options considered
- **Use `@blocknote/code-block`** (chosen): first-party BlockNote path, ships supported language aliases and a bundled Shiki highlighter, renders `.shiki` token spans in-editor, and avoids maintaining a parallel ProseMirror plugin integration.
- **Use a custom `createCodeBlockSpec({ createHighlighter })` bundle**: also valid, but Tolaria does not need a custom language/theme bundle beyond BlockNote's packaged setup right now.
- **Keep BlockNote defaults and only fix CSS**: removes the nested gray chip bug, but leaves fenced code blocks unhighlighted and fails the product requirement.
## Consequences
Tolaria's highlighting now lives in the editor schema instead of an editor-side plugin hook. `src/components/editorSchema.tsx` swaps in `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })`, which adds BlockNote's language selector plus Shiki token spans for supported fenced blocks. `EditorTheme.css` continues to keep the `pre > code` background transparent so BlockNote's dark code-block shell remains intact.
The tradeoff is one new first-party dependency and BlockNote's bundled language menu inside code blocks. If Tolaria later needs a narrower bundle, custom themes, or export-time highlighting parity, this ADR should be superseded with a custom Shiki bundle decision.

View File

@@ -0,0 +1,30 @@
---
type: ADR
id: "0064"
title: "Ratcheted CodeScene thresholds as the quality gate baseline"
status: active
date: 2026-04-14
---
## Context
ADR-0018 established CodeScene code-health gates so Tolaria could block regressions before code reached `main`. Since then, the codebase has improved materially and the tracked baseline in `.codescene-thresholds` has been ratcheted above the original 9.50 / 9.31 minimums.
Leaving ADR-0018 active would make the architecture record stale: the enforced thresholds are now stricter than the decision document says, and the current workflow intentionally tightens them as the project's sustained health improves.
## Decision
**Supersede ADR-0018 and treat `.codescene-thresholds` as the ratcheted policy baseline for Tolaria's CodeScene gate.** The current required minimums are `HOTSPOT_THRESHOLD=9.84` and `AVERAGE_THRESHOLD=9.45`. Thresholds move upward only when the repository can sustain a stricter baseline without immediately regressing.
## Options considered
- **Ratchet the enforced thresholds and document the new baseline** (chosen): keeps the ADRs aligned with the real gate, preserves the Boy Scout Rule, and makes code-health expectations stricter as the codebase improves.
- **Keep ADR-0018 active and treat higher thresholds as an implementation detail**: lower documentation churn, but the active ADR would no longer describe the actual CI and hook policy.
- **Remove numeric thresholds from ADRs entirely**: more durable on paper, but loses the explicit quality bar that developers are expected to maintain.
## Consequences
- `.codescene-thresholds` is now the authoritative location for the current numeric gate values.
- ADRs must be superseded again if Tolaria makes another meaningful policy jump in CodeScene thresholds.
- Pre-push and related quality checks now enforce a stricter floor than ADR-0018 described.
- The quality gate remains intentionally one-way: relaxing thresholds would require an explicit architectural reversal, not a quiet config edit.

View File

@@ -0,0 +1,30 @@
---
type: ADR
id: "0065"
title: "Root-managed AI guidance files with Claude shim"
status: active
date: 2026-04-14
---
## Context
Tolaria now supports multiple local CLI agents, but vault-level guidance still carried legacy assumptions. Existing vault bootstrap and repair flows centered on `config/agents.md`, while modern coding agents expect instructions at the vault root. That mismatch made managed guidance harder to reason about, left Claude Code compatibility implicit, and gave the UI no reliable way to distinguish between Tolaria-managed files that can be repaired and user-authored custom guidance that must be preserved.
## Decision
**Tolaria manages vault AI guidance at the vault root.** `AGENTS.md` is the canonical shared guidance file, `CLAUDE.md` is a compatibility shim that points Claude Code back to `AGENTS.md`, and Tolaria classifies both files as `managed`, `missing`, `broken`, or `custom` so repair flows restore only Tolaria-managed guidance without overwriting custom user files.
## Options considered
- **Root `AGENTS.md` as canonical plus a root `CLAUDE.md` shim** (chosen): matches current agent expectations, keeps one source of truth for shared instructions, and makes repair status explicit.
- **Keep managed guidance under `config/agents.md`**: preserves the older structure, but hides a user-facing integration contract behind legacy config paths and keeps Claude compatibility indirect.
- **Maintain separate full instruction files for each agent**: simple per tool, but duplicates instructions and increases drift risk whenever guidance changes.
## Consequences
- New and repaired vaults now seed `AGENTS.md` and `CLAUDE.md` at the vault root.
- Legacy `config/agents.md` content is migrated forward when safe, then the obsolete file is removed.
- The status bar and command palette can expose a first-class restore action because backend guidance state is normalized.
- Custom root guidance files are preserved instead of being silently overwritten by repair flows.
- Tolaria keeps a single shared guidance document even while supporting multiple CLI agents.
- Re-evaluate if supported agents stop relying on root-level files or if future agent integrations require materially different vault instructions instead of a shared source of truth.

View File

@@ -0,0 +1,37 @@
---
type: ADR
id: "0066"
title: "Calendar-semver versioning for alpha and stable releases"
status: active
date: 2026-04-16
supersedes: "0057"
---
## Context
ADR-0057 kept Tolaria on two updater channels and used "next stable patch" semver for alpha builds. That preserved ordering, but it no longer matched the agreed product naming:
- Alpha should display as `Alpha YYYY.M.D.N`
- Alpha should ship the technical version `YYYY.M.D-alpha.N`
- Stable should ship and display as `YYYY.M.D`
The naming change still needs to stay semver-safe when users switch between Stable and Alpha. A pure same-day calendar alpha would become older than a same-day stable promotion, so the workflow needs a monotonicity guard in addition to cleaner display strings.
## Decision
**Tolaria keeps exactly two updater channels (`stable` and `alpha`), but both now use calendar-semver release numbers.** Stable promotions use `stable-vYYYY.M.D` tags and stamp the technical version `YYYY.M.D`. Every push to `main` publishes an alpha build with technical version `YYYY.M.D-alpha.N` and display label `Alpha YYYY.M.D.N`.
If the latest stable tag already uses the current UTC calendar date, the alpha workflow advances to the next calendar day before assigning `-alpha.N`. That keeps alpha semver-newer than the most recent stable build even after a same-day promotion.
## Options considered
- **Calendar semver with a next-day safeguard** (chosen): matches the agreed naming, keeps user-facing labels clean, and preserves updater ordering across channel switches.
- **Calendar semver without a safeguard**: simplest display model, but alpha can become semver-older than Stable after a same-day promotion.
- **Keep ADR-0057's next-patch prerelease numbering**: semver-safe, but it does not match the agreed release naming or the product surfaces that should show calendar-based versions.
## Consequences
- Release workflows now compute both a technical version and a display version.
- User-facing version surfaces strip technical prerelease noise into clean labels (`Alpha YYYY.M.D.N` or `YYYY.M.D`).
- Stable promotions must use `stable-vYYYY.M.D` tags instead of patch-based semver tags.
- Alpha sequence numbers are scoped to a calendar core date and remain compatible with the updater manifests.

View File

@@ -72,9 +72,9 @@ proposed → active → superseded
| [0014](0014-git-based-vault-cache.md) | Git-based incremental vault cache | active |
| [0015](0015-auto-save-with-debounce.md) | Auto-save with 500ms debounce | active |
| [0016](0016-sentry-posthog-telemetry.md) | Sentry + PostHog telemetry with consent | active |
| [0017](0017-canary-release-channel.md) | Canary release channel and feature flags | active |
| [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | active |
| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | active |
| [0017](canary-release-channel-and-local-feature-flags.md) | Canary release channel and feature flags | superseded → [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) |
| [0018](0018-codescene-code-health-gates.md) | CodeScene code health gates in CI | superseded → [0064](0064-ratcheted-codescene-thresholds.md) |
| [0019](0019-github-device-flow-oauth.md) | GitHub device flow OAuth for vault sync | superseded → [0056](0056-system-git-cli-auth-no-provider-oauth.md) |
| [0020](0020-keyboard-first-design.md) | Keyboard-first design principle | active |
| [0021](0021-push-to-main-workflow.md) | Push directly to main (no PRs) | active |
| [0022](0022-blocknote-rich-text-editor.md) | BlockNote as the rich text editor | active |
@@ -99,9 +99,25 @@ proposed → active → superseded
| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active |
| [0042](0042-trash-auto-purge-safety-model.md) | Trash auto-purge safety model | superseded → [0045](0045-permanent-delete-no-trash.md) |
| [0043](0043-reactive-vault-state-on-save.md) | Reactive vault state: editor changes propagate immediately to all UI | active |
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | active |
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | superseded → [0055](0055-h1-is-the-only-editor-title-surface.md) |
| [0045](0045-permanent-delete-no-trash.md) | Permanent delete with confirm modal — no Trash system | active |
| [0046](0046-starter-vault-cloned-from-github.md) | Starter vault cloned from GitHub at runtime — no bundled content | active |
| [0047](0047-regex-mode-for-view-filter-conditions.md) | Regex mode for view filter conditions | active |
| [0048](0048-relative-date-expressions-in-view-filters.md) | Relative date expressions in view filter conditions | active |
| [0049](0049-per-note-icon-property.md) | Per-note icon property (_icon on individual notes) | active |
| [0050](0050-deterministic-shortcut-command-routing.md) | Deterministic shortcut command routing | superseded → [0051](0051-shared-shortcut-manifest-for-testable-routing.md) |
| [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | Shared shortcut manifest for testable routing | superseded → [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) |
| [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active |
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active |
| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active |
| [0056](0056-system-git-cli-auth-no-provider-oauth.md) | System git auth only — no provider-specific OAuth or repo APIs | active |
| [0057](0057-alpha-stable-release-channels-and-beta-cohorts.md) | Alpha/stable release channels with PostHog beta cohorts | active |
| [0058](0058-claude-code-first-launch-onboarding-gate.md) | Claude Code first-launch onboarding gate | superseded → [0062](0062-selectable-cli-ai-agents.md) |
| [0059](0059-local-only-git-commits-without-remote.md) | Local-only git commits for vaults without a remote | active |
| [0060](0060-network-aware-ui-gating-for-remote-features.md) | Network-aware UI gating for remote-dependent features | active |
| [0061](0061-ai-prompt-bridge-event-bus.md) | AI prompt bridge — module-level event bus for cross-component prompt routing | active |
| [0062](0062-selectable-cli-ai-agents.md) | Selectable CLI AI agents with a shared panel architecture | active |
| [0063](0063-blocknote-code-block-package-for-editor-highlighting.md) | BlockNote code-block package for editor syntax highlighting | active |
| [0064](0064-ratcheted-codescene-thresholds.md) | Ratcheted CodeScene thresholds as the quality gate baseline | active |
| [0065](0065-root-managed-ai-guidance-files.md) | Root-managed AI guidance files with Claude shim | active |

View File

@@ -7,12 +7,16 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
<title>laputa-scaffold</title>
<title>Tolaria</title>
</head>
<body>
<script>
// Apply saved theme before React mounts to prevent flash
var t = localStorage.getItem('laputa-theme');
var t = localStorage.getItem('tolaria-theme');
if (t === null) {
t = localStorage.getItem('laputa-theme');
if (t !== null) localStorage.setItem('tolaria-theme', t);
}
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
</script>
<div id="root"></div>

View File

@@ -1,15 +1,15 @@
#!/usr/bin/env node
/**
* Laputa MCP Server — lightweight vault tools for AI agents.
* Tolaria MCP Server — lightweight vault tools for AI agents.
*
* The agent has full shell access (bash, read, write, edit).
* These MCP tools provide Laputa-specific capabilities that
* These MCP tools provide Tolaria-specific capabilities that
* native tools cannot replace:
*
* - search_notes: full-text search across vault notes
* - get_vault_context: vault structure overview (types, note count, folders)
* - get_note: parsed frontmatter + content (convenience over raw cat)
* - open_note: signal Laputa UI to open a note as a tab
* - open_note: signal Tolaria UI to open a note as a tab
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
* - refresh_vault: trigger vault rescan so new/modified files appear
*/
@@ -87,7 +87,7 @@ const TOOLS = [
},
{
name: 'open_note',
description: 'Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.',
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
inputSchema: {
type: 'object',
properties: {
@@ -98,7 +98,7 @@ const TOOLS = [
},
{
name: 'highlight_editor',
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
inputSchema: {
type: 'object',
properties: {
@@ -110,7 +110,7 @@ const TOOLS = [
},
{
name: 'refresh_vault',
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.',
inputSchema: {
type: 'object',
properties: {
@@ -152,7 +152,7 @@ function handleOpenNote(args) {
// then signal the UI to open it in a tab.
broadcastUiAction('vault_changed', { path: args.path })
broadcastUiAction('open_tab', { path: args.path })
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
return { content: [{ type: 'text', text: `Opening ${args.path} in Tolaria` }] }
}
function handleHighlightEditor(args) {
@@ -168,7 +168,7 @@ function handleRefreshVault(args) {
// --- Server setup ---
const server = new Server(
{ name: 'laputa-mcp-server', version: '0.3.0' },
{ name: 'tolaria-mcp-server', version: '0.3.0' },
{ capabilities: { tools: {} } },
)
@@ -195,7 +195,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
console.error(`Laputa MCP server running (vault: ${VAULT_PATH})`)
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`)
}
main().catch(console.error)

View File

@@ -1,11 +1,11 @@
{
"name": "laputa-mcp-server",
"name": "tolaria-mcp-server",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "laputa-mcp-server",
"name": "tolaria-mcp-server",
"version": "0.1.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",

View File

@@ -1,7 +1,7 @@
{
"name": "laputa-mcp-server",
"name": "tolaria-mcp-server",
"version": "0.1.0",
"description": "MCP server for Laputa vault operations",
"description": "MCP server for Tolaria vault operations",
"type": "module",
"main": "index.js",
"scripts": {

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* WebSocket bridge for Laputa MCP tools.
* WebSocket bridge for Tolaria MCP tools.
*
* Exposes vault operations over WebSocket so the Laputa app frontend
* Exposes vault operations over WebSocket so the Tolaria app frontend
* can invoke MCP tools in real-time without going through stdio.
*
* Port 9710: Tool bridge — Claude/AI clients call vault tools here.
@@ -74,7 +74,7 @@ async function handleMessage(data) {
/**
* Attempt to start the UI bridge WebSocket server.
* Returns a Promise that resolves to the WebSocketServer or null if the port
* is unavailable (e.g. another Laputa instance owns it).
* is unavailable (e.g. another Tolaria instance owns it).
*/
export function startUiBridge(port = WS_UI_PORT) {
return new Promise((resolve) => {

View File

@@ -1,5 +1,5 @@
{
"name": "laputa-app",
"name": "tolaria",
"private": true,
"version": "0.1.0",
"type": "module",
@@ -13,7 +13,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",
@@ -21,6 +21,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@blocknote/code-block": "^0.46.2",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
@@ -54,7 +55,6 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"katex": "^0.16.28",
"lowlight": "^3.3.0",
"lucide-react": "^0.564.0",
"posthog-js": "^1.363.5",
"radix-ui": "^1.4.3",

View File

@@ -0,0 +1,60 @@
diff --git a/dist/blocknote-react.js b/dist/blocknote-react.js
index 6271d12d2cd5152e924c5bfe78f3b8904844dbfe..12bb96bb36d445a77d51a18d0de446a3f9f0ea59 100644
--- a/dist/blocknote-react.js
+++ b/dist/blocknote-react.js
@@ -157,6 +157,16 @@ function so(e) {
const n = "getBoundingClientRect" in e ? () => e.getBoundingClientRect() : () => e.element.getBoundingClientRect();
return () => "element" in e && (e.cacheMountedBoundingClientRect ?? !0) ? (e.element.isConnected && (t = n()), t) : n();
}
+function __bnSafeDomAtPos(e, t) {
+ const n = e.prosemirrorView;
+ if (!n || n.isDestroyed)
+ return null;
+ try {
+ return n.domAtPos(t);
+ } catch {
+ return null;
+ }
+}
const z = (e) => {
var h, b, p;
const { refs: t, floatingStyles: n, context: o } = Ze({
@@ -216,9 +226,7 @@ const Lt = (e) => {
const s = Ue(t, c.doc);
if (!s)
return;
- const { node: a } = r.prosemirrorView.domAtPos(
- s.posBeforeNode + 1
- );
+ const a = __bnSafeDomAtPos(r, s.posBeforeNode + 1)?.node;
if (a instanceof Element)
return {
element: a
@@ -3306,14 +3314,15 @@ const pi = (e) => {
);
if (!m)
return {};
- const g = m.posBeforeNode + 1, f = t.prosemirrorView.domAtPos(
+ const g = m.posBeforeNode + 1, f = __bnSafeDomAtPos(
+ t,
g + 1
- ).node;
+ )?.node;
if (!(f instanceof Element))
return {};
if (d.tableReference = { element: f }, r.rowIndex === void 0 || r.colIndex === void 0)
return d;
- const h = t.prosemirrorState.doc.resolve(g + 1).posAtIndex(r.rowIndex), b = t.prosemirrorState.doc.resolve(h + 1).posAtIndex(r.colIndex), p = t.prosemirrorView.domAtPos(b + 1).node;
+ const h = t.prosemirrorState.doc.resolve(g + 1).posAtIndex(r.rowIndex), b = t.prosemirrorState.doc.resolve(h + 1).posAtIndex(r.colIndex), p = __bnSafeDomAtPos(t, b + 1)?.node;
return p instanceof Element ? (d.cellReference = { element: p }, d.rowReference = {
element: f,
getBoundingClientRect: () => {
@@ -4371,7 +4380,7 @@ const El = (e) => {
const a = Ue(t, c.prosemirrorState.doc);
if (!a)
return;
- const u = a.posBeforeNode + 1, d = c.prosemirrorState.doc.resolve(u + 1).posAtIndex(o || 0), m = c.prosemirrorState.doc.resolve(d + 1).posAtIndex(n || 0), { node: g } = c.prosemirrorView.domAtPos(m + 1);
+ const u = a.posBeforeNode + 1, d = c.prosemirrorState.doc.resolve(u + 1).posAtIndex(o || 0), m = c.prosemirrorState.doc.resolve(d + 1).posAtIndex(n || 0), g = __bnSafeDomAtPos(c, m + 1)?.node;
if (g instanceof Element)
return g;
}, [c, t, n, o]);

View File

@@ -0,0 +1,234 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 6c65eb7d57e207a8cd1f2a2ae3bb99507c405cef..c163932957846385623eb2980bf1141eb6631db1 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) {
const pluginState = columnResizingPluginKey.getState(view.state);
if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1);
}
+function safeDispatch(view, tr) {
+ try {
+ view.dispatch(tr);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+function safeDomAtPos(view, pos) {
+ try {
+ return view.domAtPos(pos);
+ } catch (_error) {
+ return null;
+ }
+}
function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
var _view$dom$ownerDocume;
if (!view.editable) return false;
@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false;
const cell = view.state.doc.nodeAt(pluginState.activeHandle);
const width = currentColWidth(view, pluginState.activeHandle, cell.attrs);
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
+ if (width == null) return false;
+ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
startX: event.clientX,
startWidth: width
- } }));
+ } }))) return false;
function finish(event$1) {
win.removeEventListener("mouseup", finish);
win.removeEventListener("mousemove", move);
const pluginState$1 = columnResizingPluginKey.getState(view.state);
if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) {
updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth));
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
}
}
function move(event$1) {
@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
function currentColWidth(view, cellPos, { colspan, colwidth }) {
const width = colwidth && colwidth[colwidth.length - 1];
if (width) return width;
- const dom = view.domAtPos(cellPos);
- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan;
+ const dom = safeDomAtPos(view, cellPos);
+ if (!dom) return null;
+ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null;
+ if (!cellDom || typeof cellDom.offsetWidth != "number") return null;
+ let domWidth = cellDom.offsetWidth, parts = colspan;
if (colwidth) {
for (let i = 0; i < colspan; i++) if (colwidth[i]) {
domWidth -= colwidth[i];
parts--;
}
}
- return domWidth / parts;
+ return parts ? domWidth / parts : null;
}
function domCellAround(target) {
while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode;
@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) {
return Math.max(resizeMinWidth, dragging.startWidth + offset);
}
function updateHandle(view, value) {
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
}
function updateColumnWidth(view, cell, width) {
- const $cell = view.state.doc.resolve(cell);
+ let $cell;
+ try {
+ $cell = view.state.doc.resolve(cell);
+ } catch (_error) {
+ return false;
+ }
const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1);
const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
const tr = view.state.tr;
@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) {
colwidth
});
}
- if (tr.docChanged) view.dispatch(tr);
+ if (tr.docChanged) return safeDispatch(view, tr);
+ return true;
}
function displayColumnWidth(view, cell, width, defaultCellMinWidth) {
- const $cell = view.state.doc.resolve(cell);
+ let $cell;
+ try {
+ $cell = view.state.doc.resolve(cell);
+ } catch (_error) {
+ return false;
+ }
const table = $cell.node(-1), start = $cell.start(-1);
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
- let dom = view.domAtPos($cell.start(-1)).node;
+ const domAtPos = safeDomAtPos(view, $cell.start(-1));
+ let dom = domAtPos && domAtPos.node;
while (dom && dom.nodeName != "TABLE") dom = dom.parentNode;
- if (!dom) return;
+ if (!dom) return false;
updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width);
+ return true;
}
function zeroes(n) {
return Array(n).fill(0);
diff --git a/dist/index.js b/dist/index.js
index 5b4ac25594ba5722409332b1e5c812f108c9bf11..5b811ee70ff2fcb0c7587f838f00bc6fc19e906a 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -2443,6 +2443,21 @@ function handleMouseLeave(view) {
const pluginState = columnResizingPluginKey.getState(view.state);
if (pluginState && pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1);
}
+function safeDispatch(view, tr) {
+ try {
+ view.dispatch(tr);
+ return true;
+ } catch (_error) {
+ return false;
+ }
+}
+function safeDomAtPos(view, pos) {
+ try {
+ return view.domAtPos(pos);
+ } catch (_error) {
+ return null;
+ }
+}
function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
var _view$dom$ownerDocume;
if (!view.editable) return false;
@@ -2451,17 +2466,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
if (!pluginState || pluginState.activeHandle == -1 || pluginState.dragging) return false;
const cell = view.state.doc.nodeAt(pluginState.activeHandle);
const width = currentColWidth(view, pluginState.activeHandle, cell.attrs);
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
+ if (width == null) return false;
+ if (!safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: {
startX: event.clientX,
startWidth: width
- } }));
+ } }))) return false;
function finish(event$1) {
win.removeEventListener("mouseup", finish);
win.removeEventListener("mousemove", move);
const pluginState$1 = columnResizingPluginKey.getState(view.state);
if (pluginState$1 === null || pluginState$1 === void 0 ? void 0 : pluginState$1.dragging) {
updateColumnWidth(view, pluginState$1.activeHandle, draggedWidth(pluginState$1.dragging, event$1, cellMinWidth));
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setDragging: null }));
}
}
function move(event$1) {
@@ -2482,15 +2498,18 @@ function handleMouseDown(view, event, cellMinWidth, defaultCellMinWidth) {
function currentColWidth(view, cellPos, { colspan, colwidth }) {
const width = colwidth && colwidth[colwidth.length - 1];
if (width) return width;
- const dom = view.domAtPos(cellPos);
- let domWidth = dom.node.childNodes[dom.offset].offsetWidth, parts = colspan;
+ const dom = safeDomAtPos(view, cellPos);
+ if (!dom) return null;
+ const cellDom = dom.node && dom.node.childNodes ? dom.node.childNodes[dom.offset] : null;
+ if (!cellDom || typeof cellDom.offsetWidth != "number") return null;
+ let domWidth = cellDom.offsetWidth, parts = colspan;
if (colwidth) {
for (let i = 0; i < colspan; i++) if (colwidth[i]) {
domWidth -= colwidth[i];
parts--;
}
}
- return domWidth / parts;
+ return parts ? domWidth / parts : null;
}
function domCellAround(target) {
while (target && target.nodeName != "TD" && target.nodeName != "TH") target = target.classList && target.classList.contains("ProseMirror") ? null : target.parentNode;
@@ -2516,10 +2535,15 @@ function draggedWidth(dragging, event, resizeMinWidth) {
return Math.max(resizeMinWidth, dragging.startWidth + offset);
}
function updateHandle(view, value) {
- view.dispatch(view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
+ safeDispatch(view, view.state.tr.setMeta(columnResizingPluginKey, { setHandle: value }));
}
function updateColumnWidth(view, cell, width) {
- const $cell = view.state.doc.resolve(cell);
+ let $cell;
+ try {
+ $cell = view.state.doc.resolve(cell);
+ } catch (_error) {
+ return false;
+ }
const table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1);
const col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
const tr = view.state.tr;
@@ -2537,16 +2561,24 @@ function updateColumnWidth(view, cell, width) {
colwidth
});
}
- if (tr.docChanged) view.dispatch(tr);
+ if (tr.docChanged) return safeDispatch(view, tr);
+ return true;
}
function displayColumnWidth(view, cell, width, defaultCellMinWidth) {
- const $cell = view.state.doc.resolve(cell);
+ let $cell;
+ try {
+ $cell = view.state.doc.resolve(cell);
+ } catch (_error) {
+ return false;
+ }
const table = $cell.node(-1), start = $cell.start(-1);
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
- let dom = view.domAtPos($cell.start(-1)).node;
+ const domAtPos = safeDomAtPos(view, $cell.start(-1));
+ let dom = domAtPos && domAtPos.node;
while (dom && dom.nodeName != "TABLE") dom = dom.parentNode;
- if (!dom) return;
+ if (!dom) return false;
updateColumnsOnResize(table, dom.firstChild, dom, defaultCellMinWidth, col, width);
+ return true;
}
function zeroes(n) {
return Array(n).fill(0);

View File

@@ -1,18 +1,32 @@
import { defineConfig } from '@playwright/test'
const baseURL = process.env.BASE_URL || 'http://localhost:5201'
const claudeCodeOnboardingStorageState = {
cookies: [],
origins: [
{
origin: baseURL,
localStorage: [
{ name: 'tolaria:claude-code-onboarding-dismissed', value: '1' },
],
},
],
}
export default defineConfig({
testDir: './tests/smoke',
timeout: 20_000,
retries: 2,
workers: 1,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5201',
baseURL,
headless: true,
storageState: claudeCodeOnboardingStorageState,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`,
url: process.env.BASE_URL || 'http://localhost:5201',
url: baseURL,
reuseExistingServer: true,
},
})

View File

@@ -3,6 +3,17 @@ import { defineConfig } from '@playwright/test'
const baseURL = process.env.BASE_URL || 'http://127.0.0.1:41741'
const port = new URL(baseURL).port || '41741'
const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_SERVER === '1'
const claudeCodeOnboardingStorageState = {
cookies: [],
origins: [
{
origin: baseURL,
localStorage: [
{ name: 'tolaria:claude-code-onboarding-dismissed', value: '1' },
],
},
],
}
export default defineConfig({
testDir: './tests',
@@ -13,6 +24,7 @@ export default defineConfig({
use: {
baseURL,
headless: true,
storageState: claudeCodeOnboardingStorageState,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {

122
pnpm-lock.yaml generated
View File

@@ -8,6 +8,12 @@ patchedDependencies:
'@blocknote/core@0.46.2':
hash: 9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97
path: patches/@blocknote__react@0.46.2.patch
prosemirror-tables@1.8.5:
hash: cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b
path: patches/prosemirror-tables@1.8.5.patch
importers:
@@ -16,6 +22,9 @@ importers:
'@anthropic-ai/sdk':
specifier: ^0.78.0
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
@@ -24,7 +33,7 @@ importers:
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@blocknote/react':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
version: 0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@codemirror/commands':
specifier: ^6.10.2
version: 6.10.2
@@ -115,9 +124,6 @@ importers:
katex:
specifier: ^0.16.28
version: 0.16.28
lowlight:
specifier: ^3.3.0
version: 3.3.0
lucide-react:
specifier: ^0.564.0
version: 0.564.0(react@19.2.4)
@@ -363,6 +369,11 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@blocknote/code-block@0.46.2':
resolution: {integrity: sha512-/jLxECkbJjIx4K0zR0uVVz5AKt/08YGEseYuDun6ACHvW7027YRQIqHThXfMwZuRujijraoPHI3gbLXCaaRRRQ==}
peerDependencies:
'@blocknote/core': ^0.46.2
'@blocknote/core@0.46.2':
resolution: {integrity: sha512-p+QFwrTQfQC08f7JoFg9uuwbAUkGhRakCXhHhY63Wd8ARS0ktHfc9eUnJderXQTEtWfHjACjZOyt2FifX6Lalw==}
peerDependencies:
@@ -1820,9 +1831,28 @@ packages:
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
'@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
'@shikijs/engine-javascript@3.23.0':
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
'@shikijs/langs-precompiled@3.23.0':
resolution: {integrity: sha512-NVFZ+aosM+a90oUmlM8pRZkVZPFgMabCPicOaP0xklM7Lo6U6J9JqKs+ld1Z9K85k1mfQITc6r7VfZiifitPDA==}
engines: {node: '>=20'}
'@shikijs/langs@3.23.0':
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
'@shikijs/themes@3.23.0':
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
'@shikijs/types@3.22.0':
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
'@shikijs/types@3.23.0':
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -3458,6 +3488,12 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
oniguruma-to-es@4.3.5:
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -3768,6 +3804,15 @@ packages:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
rehype-format@5.0.1:
resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==}
@@ -4466,6 +4511,16 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/langs': 3.23.0
'@shikijs/langs-precompiled': 3.23.0
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
@@ -4491,7 +4546,7 @@ snapshots:
prosemirror-highlight: 0.13.1(@shikijs/types@3.22.0)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-transform@1.11.0)(prosemirror-view@1.41.6)
prosemirror-model: 1.25.4
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.6
rehype-format: 5.0.1
@@ -4519,7 +4574,7 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@blocknote/react': 0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.14(react@19.2.4)
'@mantine/utils': 6.0.22(react@19.2.4)
@@ -4538,7 +4593,7 @@ snapshots:
- sugar-high
- supports-color
'@blocknote/react@0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
'@blocknote/react@0.46.2(patch_hash=e3726d75ed65e07adb1fd48262d6a15f8e52b1cc257177aaf632937830170c97)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
@@ -5986,11 +6041,42 @@ snapshots:
'@sentry/core': 10.47.0
react: 19.2.4
'@shikijs/core@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.5
'@shikijs/langs-precompiled@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
oniguruma-to-es: 4.3.5
'@shikijs/langs@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/themes@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/types@3.22.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@3.23.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@standard-schema/spec@1.1.0': {}
@@ -6245,7 +6331,7 @@ snapshots:
prosemirror-schema-basic: 1.2.4
prosemirror-schema-list: 1.5.1
prosemirror-state: 1.4.4
prosemirror-tables: 1.8.5
prosemirror-tables: 1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b)
prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.6
@@ -7912,6 +7998,14 @@ snapshots:
dependencies:
wrappy: 1.0.2
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.5:
dependencies:
oniguruma-parser: 0.12.1
regex: 6.1.0
regex-recursion: 6.0.2
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -8099,7 +8193,7 @@ snapshots:
prosemirror-transform: 1.11.0
prosemirror-view: 1.41.6
prosemirror-tables@1.8.5:
prosemirror-tables@1.8.5(patch_hash=cd456f0d1d88a3ce11bcf53122a0e0575c0d82810eddd887adae21d6ec570a8b):
dependencies:
prosemirror-keymap: 1.2.3
prosemirror-model: 1.25.4
@@ -8318,6 +8412,16 @@ snapshots:
indent-string: 4.0.0
strip-indent: 3.0.0
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@6.1.0:
dependencies:
regex-utilities: 2.3.0
rehype-format@5.0.1:
dependencies:
'@types/hast': 3.0.4

View File

@@ -6,3 +6,5 @@ ignoredBuiltDependencies:
patchedDependencies:
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2': patches/@blocknote__react@0.46.2.patch
prosemirror-tables@1.8.5: patches/prosemirror-tables@1.8.5.patch

View File

@@ -1,359 +0,0 @@
#!/usr/bin/env node
/**
* Design Diff Analyzer
* Analyzes changes to ui-design.pen and generates implementation tasks for Claude Code
*/
import { execSync } from 'child_process';
import fs from 'fs';
function getDiff() {
try {
return execSync('git diff HEAD~1 ui-design.pen', { encoding: 'utf8' });
} catch (e) {
return '';
}
}
function analyzeDiff(diff) {
const changes = {
colors: [],
typography: [],
spacing: [],
layout: [],
content: []
};
const lines = diff.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Color changes
if (line.includes('"fill":') || line.includes('"backgroundColor":')) {
const oldMatch = lines[i-1]?.match(/"(fill|backgroundColor)":\s*"([^"]+)"/);
const newMatch = line.match(/"(fill|backgroundColor)":\s*"([^"]+)"/);
if (oldMatch && newMatch && oldMatch[2] !== newMatch[2]) {
changes.colors.push({
type: oldMatch[1],
from: oldMatch[2],
to: newMatch[2]
});
}
}
// Font size changes
if (line.includes('"fontSize":')) {
const oldMatch = lines[i-1]?.match(/"fontSize":\s*(\d+)/);
const newMatch = line.match(/"fontSize":\s*(\d+)/);
if (oldMatch && newMatch && oldMatch[1] !== newMatch[1]) {
changes.typography.push({
type: 'fontSize',
from: parseInt(oldMatch[1]),
to: parseInt(newMatch[1])
});
}
}
// Spacing/padding changes
if (line.includes('"padding":') || line.includes('"margin":') || line.includes('"gap":')) {
const oldMatch = lines[i-1]?.match(/"(padding|margin|gap)":\s*(\d+)/);
const newMatch = line.match(/"(padding|margin|gap)":\s*(\d+)/);
if (oldMatch && newMatch && oldMatch[2] !== newMatch[2]) {
changes.spacing.push({
type: oldMatch[1],
from: parseInt(oldMatch[2]),
to: parseInt(newMatch[2])
});
}
}
// Content changes (text only - no implementation needed)
if (line.includes('"content":')) {
const oldMatch = lines[i-1]?.match(/"content":\s*"([^"]+)"/);
const newMatch = line.match(/"content":\s*"([^"]+)"/);
if (oldMatch && newMatch && oldMatch[1] !== newMatch[1]) {
changes.content.push({
from: oldMatch[1],
to: newMatch[1]
});
}
}
// Layout changes - new components added
if (line.startsWith('+') && line.includes('"name":')) {
// Look back for "type": "frame" in recent lines
const frameTypeMatch = lines.slice(Math.max(0, i-5), i+1).find(l =>
l.startsWith('+') && l.includes('"type": "frame"')
);
if (frameTypeMatch) {
const nameMatch = line.match(/"name":\s*"([^"]+)"/);
if (nameMatch) {
changes.layout.push({
action: 'added',
component: nameMatch[1],
type: 'frame'
});
}
}
}
// Layout changes - components removed
if (line.startsWith('-') && line.includes('"name":')) {
const frameTypeMatch = lines.slice(Math.max(0, i-5), i+1).find(l =>
l.startsWith('-') && l.includes('"type": "frame"')
);
if (frameTypeMatch) {
const nameMatch = line.match(/"name":\s*"([^"]+)"/);
if (nameMatch) {
changes.layout.push({
action: 'removed',
component: nameMatch[1],
type: 'frame'
});
}
}
}
// Height changes (structural)
if (line.includes('"height":')) {
const oldMatch = lines[i-1]?.match(/"height":\s*(\d+)/);
const newMatch = line.match(/"height":\s*(\d+)/);
if (oldMatch && newMatch && oldMatch[1] !== newMatch[1]) {
changes.layout.push({
action: 'resized',
property: 'height',
from: parseInt(oldMatch[1]),
to: parseInt(newMatch[1])
});
}
}
}
return changes;
}
function analyzeIntent(changes) {
const intent = {
patterns: [],
systems: [],
recommendations: []
};
// Detect color-coding system for types
const accentColorIntro = changes.colors.filter(c =>
c.to.includes('accent-') && !c.from.includes('accent-')
);
if (accentColorIntro.length >= 4) {
const colors = [...new Set(accentColorIntro.map(c => c.to))];
intent.systems.push({
type: 'color-coding',
description: 'Type-based color system',
colors: colors,
explanation: 'Each entity type (Project, Procedure, Person, Topic) has its own accent color. This color should be used consistently for: icons, active states, badges, and any UI element representing that type.',
critical: '⚠️ IMPORTANT: Active states must use the color **dynamically** based on the note type, not hardcoded. Example: if a Project is selected, use accent-red; if a Procedure is selected, use accent-purple.'
});
}
// Detect contrast improvements
const contrastChanges = changes.colors.filter(c =>
c.from === '$--muted-foreground' && c.to === '$--foreground'
);
if (contrastChanges.length > 3) {
intent.patterns.push({
type: 'contrast-improvement',
description: 'Increased text contrast for better readability',
count: contrastChanges.length
});
}
// Detect spacing improvements
if (changes.spacing.length > 0) {
const avgIncrease = changes.spacing.reduce((sum, s) => sum + (s.to - s.from), 0) / changes.spacing.length;
if (avgIncrease > 0) {
intent.patterns.push({
type: 'spacing-increase',
description: 'Increased spacing for less cluttered UI',
avgIncrease: Math.round(avgIncrease)
});
}
}
return intent;
}
function generateTasks(changes, intent) {
const tasks = [];
// Color changes → update theme.json or CSS variables
if (changes.colors.length > 0) {
const colorList = changes.colors.map(c =>
` - ${c.type}: ${c.from}${c.to}`
).join('\n');
let details = `Color changes detected:\n${colorList}\n\nUpdate src/theme.json or CSS variables to match the design.`;
// Add intent explanation if color-coding system detected
const colorSystem = intent.systems.find(s => s.type === 'color-coding');
if (colorSystem) {
details += `\n\n${colorSystem.explanation}\n\n${colorSystem.critical}`;
}
tasks.push({
priority: 'high',
description: 'Update color palette',
details: details
});
}
// Typography changes → update theme.json
if (changes.typography.length > 0) {
const typoList = changes.typography.map(t =>
` - ${t.type}: ${t.from}px → ${t.to}px`
).join('\n');
tasks.push({
priority: 'medium',
description: 'Update typography',
details: `Typography changes detected:\n${typoList}\n\nUpdate src/theme.json typography settings.`
});
}
// Spacing changes → update theme.json
if (changes.spacing.length > 0) {
const spacingList = changes.spacing.map(s =>
` - ${s.type}: ${s.from}px → ${s.to}px`
).join('\n');
tasks.push({
priority: 'medium',
description: 'Update spacing',
details: `Spacing changes detected:\n${spacingList}\n\nUpdate src/theme.json spacing values.`
});
}
// Layout changes → structural modifications
if (changes.layout.length > 0) {
const added = changes.layout.filter(l => l.action === 'added');
const removed = changes.layout.filter(l => l.action === 'removed');
const resized = changes.layout.filter(l => l.action === 'resized');
let layoutDetails = 'Layout changes detected:\n\n';
if (added.length > 0) {
layoutDetails += 'Components added:\n';
added.forEach(c => layoutDetails += ` + ${c.component} (${c.type})\n`);
layoutDetails += '\n';
}
if (removed.length > 0) {
layoutDetails += 'Components removed:\n';
removed.forEach(c => layoutDetails += ` - ${c.component} (${c.type})\n`);
layoutDetails += '\n';
}
if (resized.length > 0) {
layoutDetails += 'Components resized:\n';
resized.forEach(c => layoutDetails += `${c.property}: ${c.from}px → ${c.to}px\n`);
layoutDetails += '\n';
}
layoutDetails += 'Review ui-design.pen diff and update components accordingly.';
tasks.push({
priority: 'high',
description: 'Update layout structure',
details: layoutDetails
});
}
return tasks;
}
function formatOutput(tasks, changes, intent) {
if (tasks.length === 0 && changes.content.length === 0) {
return null;
}
let output = '🎨 Design changes detected:\n\n';
// Add design intent analysis
if (intent.systems.length > 0 || intent.patterns.length > 0) {
output += '🧠 Design Intent Analysis:\n\n';
intent.systems.forEach(system => {
output += `📐 ${system.description}\n`;
output += `${system.explanation}\n`;
if (system.critical) {
output += `\n${system.critical}\n`;
}
output += '\n';
});
intent.patterns.forEach(pattern => {
output += `${pattern.description}`;
if (pattern.count) output += ` (${pattern.count} changes)`;
if (pattern.avgIncrease) output += ` (+${pattern.avgIncrease}px avg)`;
output += '\n';
});
output += '\n';
}
if (tasks.length > 0) {
output += '📋 Implementation tasks:\n\n';
tasks.forEach((task, i) => {
output += `${i + 1}. [${task.priority.toUpperCase()}] ${task.description}\n`;
output += `${task.details}\n\n`;
});
}
if (changes.content.length > 0) {
output += '📝 Content changes (mockup text only, no code changes needed):\n';
changes.content.forEach(c => {
output += ` - "${c.from}" → "${c.to}"\n`;
});
output += '\n';
}
return { output, tasks, intent };
}
function main() {
const diff = getDiff();
if (!diff) {
console.log('No design changes detected');
process.exit(0);
}
const changes = analyzeDiff(diff);
const intent = analyzeIntent(changes);
const tasks = generateTasks(changes, intent);
const result = formatOutput(tasks, changes, intent);
if (!result) {
console.log('No significant design changes detected');
process.exit(0);
}
console.log(result.output);
// Output JSON for automation
if (process.argv.includes('--json')) {
console.log('\n---JSON---');
console.log(JSON.stringify({ tasks, changes, intent }, null, 2));
}
process.exit(tasks.length > 0 ? 1 : 0); // Exit 1 if there are tasks to implement
}
main();

View File

@@ -1,928 +0,0 @@
#!/usr/bin/env python3
"""
Restructure ui-design.pen into a proper design system.
Sections (top → bottom):
0. Cover — title, TOC
1. Foundations — colors, typography, spacing, shadows, heights
2. Components — atoms → molecules → organisms (reusable)
3. Full Layouts — 6 app variants at 1440×900
4. Feature Specs — existing frames reorganized by functional area
"""
import json
import copy
import sys
import os
PEN_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ui-design.pen")
def load():
with open(PEN_FILE, "r") as f:
return json.load(f)
def save(data):
with open(PEN_FILE, "w") as f:
json.dump(data, f, separators=(",", ":"))
# ─── Variable definitions ─────────────────────────────────────────
NEW_VARIABLES = {
"--spacing-xs": {"type": "number", "value": 4},
"--spacing-sm": {"type": "number", "value": 8},
"--spacing-md": {"type": "number", "value": 12},
"--spacing-lg": {"type": "number", "value": 16},
"--spacing-xl": {"type": "number", "value": 24},
"--spacing-2xl": {"type": "number", "value": 32},
"--spacing-3xl": {"type": "number", "value": 40},
"--height-titlebar": {"type": "number", "value": 38},
"--height-tabbar": {"type": "number", "value": 45},
"--height-breadcrumb": {"type": "number", "value": 45},
"--height-statusbar": {"type": "number", "value": 30},
"--height-search-bar": {"type": "number", "value": 40},
"--height-note-item": {"type": "number", "value": 64},
"--height-inspector-header": {"type": "number", "value": 36},
"--height-modal-header": {"type": "number", "value": 56},
"--height-modal-footer": {"type": "number", "value": 56},
"--shadow-sm": {"type": "string", "value": "0 1px 2px rgba(0,0,0,0.05)"},
"--shadow-md": {"type": "string", "value": "0 4px 6px rgba(0,0,0,0.07)"},
"--shadow-lg": {"type": "string", "value": "0 10px 15px rgba(0,0,0,0.1)"},
"--font-mono": {"type": "string", "value": "IBM Plex Mono, monospace"},
# Search panel colors (currently hardcoded)
"--search-bg": {"type": "color", "value": [
{"value": "#FFFFFF"}, {"theme": {"Mode": "Dark"}, "value": "#1E1E2E"}
]},
"--search-input-bg": {"type": "color", "value": [
{"value": "#F0F0EF"}, {"theme": {"Mode": "Dark"}, "value": "#2A2A3C"}
]},
}
# ─── Hardcoded color mappings ─────────────────────────────────────
# Maps hardcoded hex → variable name for replacement
HARDCODED_COLORS = {
"#1E1E2E": "$--search-bg",
"#1e1e2e": "$--search-bg",
"#2A2A3C": "$--search-input-bg",
"#2a2a3c": "$--search-input-bg",
"#6b7280": "$--muted-foreground",
"#f9fafb": "$--muted",
"#f3f4f6": "$--muted",
"#ffffff": "$--background",
"#000000": "$--black",
"#6366f1": "$--primary",
}
def replace_hardcoded_colors(node):
"""Recursively replace hardcoded hex colors with variable references."""
if isinstance(node, dict):
for key, val in list(node.items()):
if key == "fill" and isinstance(val, str) and val.startswith("#"):
lower = val.lower()
if lower in HARDCODED_COLORS:
node[key] = HARDCODED_COLORS[lower]
elif key == "fill" and isinstance(val, dict) and val.get("type") == "color":
color = val.get("color", "")
if isinstance(color, str) and color.lower() in HARDCODED_COLORS:
val["color"] = HARDCODED_COLORS[color.lower()]
elif isinstance(val, (dict, list)):
replace_hardcoded_colors(val)
elif isinstance(node, list):
for item in node:
replace_hardcoded_colors(item)
# ─── Section header helper ────────────────────────────────────────
def make_section_header(sec_id, label, x, y):
return {
"type": "frame", "id": sec_id, "name": label,
"x": x, "y": y, "width": 1440, "height": 60,
"fill": "$--foreground", "padding": [0, 60],
"alignItems": "center", "theme": {"Mode": "Light"},
"children": [{
"type": "text", "id": f"{sec_id}_lbl",
"content": label.upper(),
"fill": "$--background", "fontFamily": "Inter",
"fontSize": 24, "fontWeight": "700", "letterSpacing": 2
}]
}
def make_subsection_label(sub_id, label, x, y):
return {
"type": "text", "id": sub_id,
"content": label,
"x": x, "y": y,
"fill": "$--muted-foreground", "fontFamily": "Inter",
"fontSize": 16, "fontWeight": "600", "letterSpacing": 1,
"theme": {"Mode": "Light"}
}
# ─── Cover ────────────────────────────────────────────────────────
def make_cover(x, y):
return {
"type": "frame", "id": "ds_cover", "name": "0 — Cover",
"x": x, "y": y, "width": 1440, "height": "fit_content(400)",
"fill": "$--background", "layout": "vertical",
"gap": 32, "padding": [80, 60],
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "ds_title", "content": "Laputa Design System",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 48,
"fontWeight": "700", "letterSpacing": -1.5},
{"type": "text", "id": "ds_subtitle",
"content": "Wiki-linked knowledge management for deep thinkers",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 18, "lineHeight": 1.5},
{"type": "rectangle", "id": "ds_div", "width": "fill_container", "height": 1, "fill": "$--border"},
{"type": "frame", "id": "ds_toc", "name": "TOC", "layout": "vertical",
"width": "fill_container", "gap": 16, "children": [
{"type": "text", "id": "ds_toc_t", "content": "Table of Contents",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 20, "fontWeight": "600"},
{"type": "frame", "id": "ds_toc_items", "layout": "vertical",
"width": "fill_container", "gap": 8, "children": [
{"type": "text", "id": "ds_t1", "fill": "$--primary", "fontFamily": "Inter",
"fontSize": 14, "fontWeight": "500",
"content": "1. Foundations — Colors, Typography, Spacing, Shadows, Heights"},
{"type": "text", "id": "ds_t2", "fill": "$--primary", "fontFamily": "Inter",
"fontSize": 14, "fontWeight": "500",
"content": "2. Components — Atoms, Molecules, Organisms"},
{"type": "text", "id": "ds_t3", "fill": "$--primary", "fontFamily": "Inter",
"fontSize": 14, "fontWeight": "500",
"content": "3. Full Layouts — 6 app variants at 1440×900"},
{"type": "text", "id": "ds_t4", "fill": "$--primary", "fontFamily": "Inter",
"fontSize": 14, "fontWeight": "500",
"content": "4. Feature Specs — All screens grouped by functional area"},
]}
]}
]
}
# ─── Foundation frames ────────────────────────────────────────────
def make_spacing_scale(x, y):
rows = []
for var, px in [("xs", 4), ("sm", 8), ("md", 12), ("lg", 16), ("xl", 24), ("2xl", 32), ("3xl", 40)]:
rid = f"sp_{var}"
rows.append({
"type": "frame", "id": rid, "layout": "horizontal",
"gap": 16, "alignItems": "center", "width": "fill_container",
"children": [
{"type": "rectangle", "id": f"{rid}_box", "width": px, "height": 24,
"fill": "$--primary", "cornerRadius": 2},
{"type": "text", "id": f"{rid}_lbl",
"content": f"--spacing-{var}: {px}px",
"fill": "$--foreground", "fontFamily": "IBM Plex Mono", "fontSize": 13}
]
})
return {
"type": "frame", "id": "ds_spacing", "name": "Spacing Scale",
"x": x, "y": y, "width": 700,
"fill": "$--background", "layout": "vertical",
"gap": 24, "padding": 40, "theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "ds_sp_title", "content": "Spacing Scale",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 20, "fontWeight": "600"},
{"type": "text", "id": "ds_sp_sub",
"content": "Consistent spacing tokens used throughout the app.",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 13, "lineHeight": 1.5},
] + rows
}
def make_heights_ref(x, y):
items = [
("titlebar", 38, "$--sidebar"), ("tabbar", 45, "$--sidebar"),
("breadcrumb", 45, "$--background"), ("statusbar", 30, "$--sidebar"),
("modal-header", 56, "$--background"), ("inspector-header", 36, "$--background"),
]
rows = []
for name, px, fill in items:
rid = f"ht_{name.replace('-','_')}"
rows.append({
"type": "frame", "id": rid, "layout": "horizontal",
"gap": 16, "alignItems": "center", "width": "fill_container",
"children": [
{"type": "rectangle", "id": f"{rid}_bar", "width": 120, "height": px,
"fill": fill, "cornerRadius": "$--radius-sm",
"stroke": {"fill": "$--border", "thickness": 1}},
{"type": "text", "id": f"{rid}_lbl",
"content": f"--height-{name}: {px}px",
"fill": "$--foreground", "fontFamily": "IBM Plex Mono", "fontSize": 13}
]
})
return {
"type": "frame", "id": "ds_heights", "name": "Height Variables",
"x": x, "y": y, "width": 700,
"fill": "$--background", "layout": "vertical",
"gap": 24, "padding": 40, "theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "ds_ht_title", "content": "Height Variables",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 20, "fontWeight": "600"},
{"type": "text", "id": "ds_ht_sub",
"content": "Fixed heights for key layout regions.",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 13, "lineHeight": 1.5},
] + rows
}
def make_shadows_ref(x, y):
shadows = [
("SM", "0 1px 2px rgba(0,0,0,0.05)", {"type": "shadow", "offset": {"x": 0, "y": 1}, "blur": 2, "color": "#0000000D"}),
("MD", "0 4px 6px rgba(0,0,0,0.07)", {"type": "shadow", "offset": {"x": 0, "y": 4}, "blur": 6, "color": "#00000012"}),
("LG", "0 10px 15px rgba(0,0,0,0.1)", {"type": "shadow", "offset": {"x": 0, "y": 10}, "blur": 15, "color": "#0000001A"}),
]
rows = []
for name, desc, effect in shadows:
rid = f"sh_{name.lower()}"
rows.append({
"type": "frame", "id": rid, "layout": "horizontal",
"gap": 24, "alignItems": "center", "width": "fill_container",
"children": [
{"type": "frame", "id": f"{rid}_box", "width": 120, "height": 80,
"fill": "$--card", "cornerRadius": "$--radius-lg", "effect": effect},
{"type": "frame", "id": f"{rid}_info", "layout": "vertical", "gap": 4, "children": [
{"type": "text", "id": f"{rid}_name", "content": f"Shadow {name}",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 14, "fontWeight": "600"},
{"type": "text", "id": f"{rid}_desc", "content": desc,
"fill": "$--muted-foreground", "fontFamily": "IBM Plex Mono", "fontSize": 11}
]}
]
})
return {
"type": "frame", "id": "ds_shadows", "name": "Shadows",
"x": x, "y": y, "width": 700,
"fill": "$--background", "layout": "vertical",
"gap": 32, "padding": 40, "theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "ds_sh_title", "content": "Shadows",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 20, "fontWeight": "600"},
{"type": "text", "id": "ds_sh_sub",
"content": "Elevation levels for cards, modals, and popovers.",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 13, "lineHeight": 1.5},
] + rows
}
# ─── Reusable Components ─────────────────────────────────────────
def make_component_status_dot(x, y):
return {
"type": "frame", "id": "comp_StatusDot", "name": "component/StatusDot",
"reusable": True, "x": x, "y": y,
"width": "fit_content(6)", "height": "fit_content(6)",
"theme": {"Mode": "Light"},
"children": [
{"type": "ellipse", "id": "StatusDot_dot", "width": 6, "height": 6,
"fill": "$--accent-orange"}
]
}
def make_component_separator(x, y):
return {
"type": "frame", "id": "comp_Separator", "name": "component/Separator",
"reusable": True, "x": x, "y": y,
"width": "fill_container(200)", "height": 1,
"fill": "$--border",
"theme": {"Mode": "Light"},
"children": []
}
def make_component_badge(x, y):
"""Colored pill badge — used for type badges, status badges, tags."""
return {
"type": "frame", "id": "comp_Badge", "name": "component/Badge",
"reusable": True, "x": x, "y": y,
"cornerRadius": 12, "fill": "$--accent-blue-light",
"padding": [2, 8],
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "Badge_label", "content": "Badge",
"fill": "$--accent-blue", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500"}
]
}
def make_component_button_primary(x, y):
return {
"type": "frame", "id": "comp_ButtonPrimary", "name": "component/Button/Primary",
"reusable": True, "x": x, "y": y,
"cornerRadius": "$--radius-md", "fill": "$--primary",
"padding": [8, 16], "gap": 6, "alignItems": "center",
"justifyContent": "center", "height": 36,
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "BtnPri_label", "content": "Button",
"fill": "$--primary-foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"}
]
}
def make_component_button_secondary(x, y):
return {
"type": "frame", "id": "comp_ButtonSecondary", "name": "component/Button/Secondary",
"reusable": True, "x": x, "y": y,
"cornerRadius": "$--radius-md", "fill": "$--secondary",
"padding": [8, 16], "gap": 6, "alignItems": "center",
"justifyContent": "center", "height": 36,
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "BtnSec_label", "content": "Button",
"fill": "$--secondary-foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"}
]
}
def make_component_button_ghost(x, y):
return {
"type": "frame", "id": "comp_ButtonGhost", "name": "component/Button/Ghost",
"reusable": True, "x": x, "y": y,
"cornerRadius": "$--radius-md",
"padding": [8, 16], "gap": 6, "alignItems": "center",
"justifyContent": "center", "height": 36,
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "BtnGho_label", "content": "Button",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"}
]
}
def make_component_input(x, y):
return {
"type": "frame", "id": "comp_Input", "name": "component/Input",
"reusable": True, "x": x, "y": y,
"width": 240, "height": 36,
"cornerRadius": "$--radius-md", "fill": "$--background",
"stroke": {"fill": "$--input", "thickness": 1, "align": "inside"},
"padding": [0, 12], "alignItems": "center",
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "Input_placeholder", "content": "Placeholder...",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 13}
]
}
def make_component_icon_button(x, y):
return {
"type": "frame", "id": "comp_IconButton", "name": "component/IconButton",
"reusable": True, "x": x, "y": y,
"width": 28, "height": 28,
"cornerRadius": "$--radius-sm",
"alignItems": "center", "justifyContent": "center",
"theme": {"Mode": "Light"},
"children": [
{"type": "icon_font", "id": "IconBtn_icon", "width": 16, "height": 16,
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "plus"}
]
}
# ─── Molecule Components ──────────────────────────────────────────
def make_component_inspector_header(x, y):
return {
"type": "frame", "id": "comp_InspectorHeader", "name": "component/InspectorHeader",
"reusable": True, "x": x, "y": y,
"width": "fill_container(320)", "height": "$--height-inspector-header",
"justifyContent": "space_between", "alignItems": "center",
"padding": [0, 12],
"theme": {"Mode": "Light"},
"children": [
{"type": "frame", "id": "IH_left", "name": "leftGroup",
"gap": 6, "alignItems": "center", "children": [
{"type": "icon_font", "id": "IH_icon", "width": 16, "height": 16,
"fill": "$--muted-foreground", "iconFontFamily": "phosphor",
"iconFontName": "sliders-horizontal"},
{"type": "text", "id": "IH_title", "content": "Properties",
"fill": "$--muted-foreground", "fontFamily": "Inter",
"fontSize": 13, "fontWeight": "600"}
]},
{"type": "icon_font", "id": "IH_close", "width": 16, "height": 16,
"fill": "$--muted-foreground", "iconFontFamily": "phosphor", "iconFontName": "x"}
]
}
def make_component_note_list_item(x, y):
return {
"type": "frame", "id": "comp_NoteListItem", "name": "component/NoteListItem",
"reusable": True, "x": x, "y": y,
"width": "fill_container(340)", "layout": "vertical",
"gap": 4, "padding": [14, 16],
"stroke": {"align": "inside", "fill": "$--border", "thickness": {"bottom": 1}},
"theme": {"Mode": "Light"},
"children": [
{"type": "frame", "id": "NLI_titleRow", "name": "titleRow",
"width": "fill_container", "justifyContent": "space_between", "alignItems": "center",
"children": [
{"type": "frame", "id": "NLI_titleGroup", "gap": 6, "alignItems": "center",
"children": [
{"type": "text", "id": "NLI_title", "content": "Note Title",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"}
]},
{"type": "text", "id": "NLI_time", "content": "2m ago",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 11}
]},
{"type": "text", "id": "NLI_snippet",
"content": "Preview text of the note content...",
"fill": "$--muted-foreground", "fontFamily": "Inter",
"fontSize": 12, "lineHeight": 1.5,
"textGrowth": "fixed-width", "width": "fill_container"},
]
}
def make_component_tab_active(x, y):
return {
"type": "frame", "id": "comp_TabActive", "name": "component/Tab/Active",
"reusable": True, "x": x, "y": y,
"height": "fill_container(45)", "gap": 6,
"fill": "$--background", "alignItems": "center",
"padding": [0, 14],
"stroke": {"align": "inside", "fill": "$--border", "thickness": {"right": 1}},
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "TabA_label", "content": "Tab Title",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500"},
{"type": "icon_font", "id": "TabA_close", "width": 14, "height": 14,
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "x"}
]
}
def make_component_tab_inactive(x, y):
return {
"type": "frame", "id": "comp_TabInactive", "name": "component/Tab/Inactive",
"reusable": True, "x": x, "y": y,
"height": "fill_container(45)", "gap": 6,
"alignItems": "center", "padding": [0, 14],
"stroke": {"align": "inside", "fill": "$--sidebar-border",
"thickness": {"bottom": 1, "right": 1}},
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "TabI_label", "content": "Tab Title",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 12},
{"type": "icon_font", "id": "TabI_close", "width": 14, "height": 14,
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "x",
"opacity": 0}
]
}
def make_component_property_row(x, y):
return {
"type": "frame", "id": "comp_PropertyRow", "name": "component/PropertyRow",
"reusable": True, "x": x, "y": y,
"width": "fill_container(300)", "alignItems": "center",
"cornerRadius": 4, "padding": [4, 6],
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "PR_label", "content": "LABEL",
"fill": "$--muted-foreground", "fontFamily": "IBM Plex Mono",
"fontSize": 10, "fontWeight": "500", "letterSpacing": 1.2},
{"type": "text", "id": "PR_value", "content": "Value",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13}
]
}
def make_component_relationship_pill(x, y):
return {
"type": "frame", "id": "comp_RelationshipPill", "name": "component/RelationshipPill",
"reusable": True, "x": x, "y": y,
"width": "fill_container(280)", "cornerRadius": 6,
"fill": "$--accent-blue-light", "gap": 6,
"justifyContent": "space_between", "alignItems": "center",
"padding": [6, 10],
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "RP_title", "content": "Related Note",
"fill": "$--accent-blue", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "500"},
{"type": "icon_font", "id": "RP_icon", "width": 14, "height": 14,
"fill": "$--accent-blue", "iconFontFamily": "phosphor", "iconFontName": "wrench",
"weight": 700}
]
}
def make_component_sidebar_filter_item(x, y):
return {
"type": "frame", "id": "comp_SidebarFilterItem", "name": "component/SidebarFilterItem",
"reusable": True, "x": x, "y": y,
"width": "fill_container(250)", "cornerRadius": 6,
"gap": 8, "alignItems": "center", "padding": [6, 16],
"theme": {"Mode": "Light"},
"children": [
{"type": "icon_font", "id": "SFI_icon", "width": 16, "height": 16,
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "folder"},
{"type": "text", "id": "SFI_label", "content": "Filter Item",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13},
{"type": "text", "id": "SFI_count", "content": "42",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 11}
]
}
def make_component_section_label(x, y):
return {
"type": "frame", "id": "comp_SectionLabel", "name": "component/SectionLabel",
"reusable": True, "x": x, "y": y,
"width": "fill_container(280)", "padding": [0, 12],
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "SL_label", "content": "SECTION",
"fill": "$--muted-foreground", "fontFamily": "Inter",
"fontSize": 10, "fontWeight": "600", "letterSpacing": 1.2}
]
}
def make_component_section_count_header(x, y):
return {
"type": "frame", "id": "comp_SectionCountHeader", "name": "component/SectionCountHeader",
"reusable": True, "x": x, "y": y,
"width": "fill_container(280)", "justifyContent": "space_between",
"alignItems": "center", "padding": [4, 0],
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "SCH_label", "content": "SECTION",
"fill": "$--muted-foreground", "fontFamily": "Inter",
"fontSize": 10, "fontWeight": "600", "letterSpacing": 1.2},
{"type": "text", "id": "SCH_count", "content": "3",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 11}
]
}
def make_component_add_button(x, y):
return {
"type": "frame", "id": "comp_AddButton", "name": "component/AddButton",
"reusable": True, "x": x, "y": y,
"width": "fill_container(280)", "height": 32,
"cornerRadius": 6, "justifyContent": "center", "alignItems": "center",
"stroke": {"align": "inside", "fill": "$--border", "thickness": 1},
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "AB_label", "content": "+ Add property",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 12}
]
}
def make_component_rel_group_label(x, y):
return {
"type": "frame", "id": "comp_RelGroupLabel", "name": "component/RelGroupLabel",
"reusable": True, "x": x, "y": y,
"width": "fill_container(280)", "justifyContent": "space_between",
"alignItems": "center",
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "RGL_label", "content": "BELONGS TO",
"fill": "$--muted-foreground", "fontFamily": "Inter",
"fontSize": 10, "fontWeight": "600", "letterSpacing": 0.5},
{"type": "text", "id": "RGL_count", "content": "3",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 10}
]
}
def make_component_command_palette_item(x, y):
return {
"type": "frame", "id": "comp_CommandPaletteItem", "name": "component/CommandPaletteItem",
"reusable": True, "x": x, "y": y,
"width": "fill_container(460)", "height": 40,
"alignItems": "center", "gap": 12, "padding": [0, 16],
"cornerRadius": "$--radius-md",
"theme": {"Mode": "Light"},
"children": [
{"type": "icon_font", "id": "CPI_icon", "width": 16, "height": 16,
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "file-text"},
{"type": "frame", "id": "CPI_content", "layout": "vertical",
"width": "fill_container", "gap": 2, "children": [
{"type": "text", "id": "CPI_title", "content": "Command Name",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "500"},
]},
{"type": "text", "id": "CPI_shortcut", "content": "⌘K",
"fill": "$--muted-foreground", "fontFamily": "IBM Plex Mono", "fontSize": 11}
]
}
def make_component_editable_value(x, y):
return {
"type": "frame", "id": "comp_EditableValue", "name": "component/EditableValue",
"reusable": True, "x": x, "y": y,
"width": "fill_container(200)",
"alignItems": "center", "gap": 4,
"theme": {"Mode": "Light"},
"children": [
{"type": "text", "id": "EV_value", "content": "Editable Value",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13},
{"type": "icon_font", "id": "EV_edit", "width": 12, "height": 12,
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "pencil",
"opacity": 0}
]
}
# ─── Organism Components ──────────────────────────────────────────
def make_component_toast(x, y):
return {
"type": "frame", "id": "comp_Toast", "name": "component/Toast",
"reusable": True, "x": x, "y": y,
"width": 360, "cornerRadius": "$--radius-lg",
"fill": "$--card", "padding": [12, 16], "gap": 12,
"alignItems": "center",
"stroke": {"fill": "$--border", "thickness": 1},
"effect": {"type": "shadow", "offset": {"x": 0, "y": 4}, "blur": 12, "color": "#0000001A"},
"theme": {"Mode": "Light"},
"children": [
{"type": "icon_font", "id": "Toast_icon", "width": 20, "height": 20,
"fill": "$--accent-green", "iconFontFamily": "lucide", "iconFontName": "check-circle"},
{"type": "frame", "id": "Toast_content", "layout": "vertical",
"width": "fill_container", "gap": 2, "children": [
{"type": "text", "id": "Toast_title", "content": "Success",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13, "fontWeight": "600"},
{"type": "text", "id": "Toast_msg", "content": "Your changes have been saved.",
"fill": "$--muted-foreground", "fontFamily": "Inter", "fontSize": 12, "lineHeight": 1.4}
]},
{"type": "icon_font", "id": "Toast_close", "width": 14, "height": 14,
"fill": "$--muted-foreground", "iconFontFamily": "lucide", "iconFontName": "x"}
]
}
def make_component_ai_chat_message(x, y):
return {
"type": "frame", "id": "comp_AIChatMessage", "name": "component/AIChatMessage",
"reusable": True, "x": x, "y": y,
"width": "fill_container(400)", "layout": "horizontal",
"gap": 12, "padding": [12, 16],
"theme": {"Mode": "Light"},
"children": [
{"type": "frame", "id": "AIM_avatar", "width": 28, "height": 28,
"cornerRadius": 14, "fill": "$--accent-purple-light",
"alignItems": "center", "justifyContent": "center",
"children": [
{"type": "icon_font", "id": "AIM_avatarIcon", "width": 16, "height": 16,
"fill": "$--accent-purple", "iconFontFamily": "lucide", "iconFontName": "sparkles"}
]},
{"type": "frame", "id": "AIM_body", "layout": "vertical",
"width": "fill_container", "gap": 4, "children": [
{"type": "text", "id": "AIM_sender", "content": "AI Assistant",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 12, "fontWeight": "600"},
{"type": "text", "id": "AIM_text",
"content": "Here's a summary of your recent notes...",
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 13,
"lineHeight": 1.5, "textGrowth": "fixed-width", "width": "fill_container"}
]}
]
}
# ─── Component showcase frame ─────────────────────────────────────
def make_component_showcase(x, y, title, components):
"""Creates a frame showing all component instances in a row for visual reference."""
children = [
{"type": "text", "id": f"showcase_{title.replace(' ','_')}_title",
"content": title,
"fill": "$--foreground", "fontFamily": "Inter", "fontSize": 18, "fontWeight": "600"},
]
for comp_id, label in components:
children.append({
"type": "frame", "id": f"show_{comp_id}", "layout": "vertical", "gap": 8,
"padding": [12, 0], "children": [
{"type": "text", "id": f"show_{comp_id}_lbl", "content": label,
"fill": "$--muted-foreground", "fontFamily": "IBM Plex Mono", "fontSize": 10,
"letterSpacing": 0.5},
{"type": "ref", "id": f"show_{comp_id}_ref", "ref": comp_id}
]
})
sid = f"showcase_{title.replace(' ', '_')}"
return {
"type": "frame", "id": sid, "name": f"Showcase — {title}",
"x": x, "y": y, "width": 1440,
"fill": "$--background", "layout": "vertical",
"gap": 24, "padding": 40,
"theme": {"Mode": "Light"},
"children": children
}
# ─── Feature spec grouping ────────────────────────────────────────
FEATURE_GROUPS = {
"Tabs & Navigation": [
"dt0", "dtj", "dt11", "rnt0", "rne0", "rns0",
"archBtnNorm", "archBtnArc", "srtDD", "srtBN"
],
"Inspector & Properties": [
"pi01", "pi50", "rbF01", "rbF02", "rbF03",
"reF01", "reF02", "reF03",
"urlDefault", "urlHover", "urlEdit"
],
"Note List & Virtual List": [
"vl001", "vl100", "vl200", "vl300",
"mni01", "dp01"
],
"Sidebar & Sections": [
"dsg01a", "dsg02w", "dsg04j", "csB01", "csA01", "trSB1"
],
"Modals & Settings": [
"iogBH", "ghv01", "ghv02", "ghv03", "ghv04"
],
"Editor & Wikilinks": [
"wlc01", "wlc20", "mb001", "mb011"
],
"Status & Indicators": [
"mni10", "mni20", "dp10", "dp20", "dp30"
],
"Git & Changes": [
"vc001", "vc100", "vc200", "ghCL1", "ghDO1"
],
"Search": [
"3aG9b", "K1O2x", "nrIcZ"
],
"Trash": [
"trNL1", "trRI1", "trW30"
],
}
# ─── Main transform ──────────────────────────────────────────────
def transform():
data = load()
# 1. Add new variables (merge, don't replace)
for k, v in NEW_VARIABLES.items():
data["variables"][k] = v
# 2. Build an index of existing children by ID
existing = {c["id"]: c for c in data["children"]}
# 3. Fix hardcoded colors in ALL existing frames
for child in data["children"]:
replace_hardcoded_colors(child)
# 4. Build new ordered children list
new_children = []
# ─── Section 0: Cover (y=0) ───
new_children.append(make_cover(0, 0))
# ─── Section 1: Foundations (y=500) ───
new_children.append(make_section_header("sec1_hdr", "1 — Foundations", 0, 500))
# Move existing Color Palette
if "mOf4J" in existing:
cp = existing.pop("mOf4J")
cp["x"] = 0; cp["y"] = 600
new_children.append(cp)
# Move existing Typography & Spacing
if "HZonq" in existing:
ts = existing.pop("HZonq")
ts["x"] = 0; ts["y"] = 1600
new_children.append(ts)
# New foundation frames
new_children.append(make_spacing_scale(0, 3000))
new_children.append(make_heights_ref(800, 3000))
new_children.append(make_shadows_ref(0, 3600))
# ─── Section 2: Components (y=4200) ───
new_children.append(make_section_header("sec2_hdr", "2 — Components", 0, 4200))
# Subsection labels
new_children.append(make_subsection_label("sub_atoms", "ATOMS", 0, 4290))
# Atom components (placed as reusable definitions)
comp_y_atoms = 4320
atom_components = [
make_component_status_dot(0, comp_y_atoms),
make_component_separator(100, comp_y_atoms),
make_component_badge(400, comp_y_atoms),
make_component_button_primary(0, comp_y_atoms + 60),
make_component_button_secondary(200, comp_y_atoms + 60),
make_component_button_ghost(400, comp_y_atoms + 60),
make_component_input(0, comp_y_atoms + 120),
make_component_icon_button(300, comp_y_atoms + 120),
]
new_children.extend(atom_components)
# Atom showcase
new_children.append(make_component_showcase(0, comp_y_atoms + 200, "Atoms", [
("comp_StatusDot", "StatusDot"),
("comp_Separator", "Separator"),
("comp_Badge", "Badge"),
("comp_ButtonPrimary", "Button/Primary"),
("comp_ButtonSecondary", "Button/Secondary"),
("comp_ButtonGhost", "Button/Ghost"),
("comp_Input", "Input"),
("comp_IconButton", "IconButton"),
]))
# Subsection: Molecules
mol_y = 4900
new_children.append(make_subsection_label("sub_molecules", "MOLECULES", 0, mol_y - 30))
molecule_components = [
make_component_inspector_header(0, mol_y),
make_component_note_list_item(0, mol_y + 60),
make_component_tab_active(400, mol_y),
make_component_tab_inactive(600, mol_y),
make_component_property_row(0, mol_y + 160),
make_component_relationship_pill(0, mol_y + 220),
make_component_sidebar_filter_item(400, mol_y + 160),
make_component_section_label(0, mol_y + 280),
make_component_section_count_header(400, mol_y + 280),
make_component_add_button(0, mol_y + 340),
make_component_rel_group_label(400, mol_y + 340),
make_component_command_palette_item(0, mol_y + 400),
make_component_editable_value(0, mol_y + 460),
]
new_children.extend(molecule_components)
# Molecule showcase
new_children.append(make_component_showcase(0, mol_y + 520, "Molecules", [
("comp_InspectorHeader", "InspectorHeader"),
("comp_NoteListItem", "NoteListItem"),
("comp_TabActive", "Tab/Active"),
("comp_TabInactive", "Tab/Inactive"),
("comp_PropertyRow", "PropertyRow"),
("comp_RelationshipPill", "RelationshipPill"),
("comp_SidebarFilterItem", "SidebarFilterItem"),
("comp_SectionLabel", "SectionLabel"),
("comp_SectionCountHeader", "SectionCountHeader"),
("comp_AddButton", "AddButton"),
("comp_RelGroupLabel", "RelGroupLabel"),
("comp_CommandPaletteItem", "CommandPaletteItem"),
("comp_EditableValue", "EditableValue"),
]))
# Subsection: Organisms
org_y = 5900
new_children.append(make_subsection_label("sub_organisms", "ORGANISMS", 0, org_y - 30))
new_children.append(make_component_toast(0, org_y))
new_children.append(make_component_ai_chat_message(0, org_y + 80))
new_children.append(make_component_showcase(0, org_y + 200, "Organisms", [
("comp_Toast", "Toast"),
("comp_AIChatMessage", "AIChatMessage"),
]))
# ─── Section 3: Full Layouts (y=6600) ───
new_children.append(make_section_header("sec3_hdr", "3 — Full Layouts", 0, 6600))
# Layout frames — move existing ones
layout_ids = ["qHhaj", "SC_all", "SC_nosb", "SC_edonly", "SC_ednl"]
layout_y = 6700
for lid in layout_ids:
if lid in existing:
frame = existing.pop(lid)
frame["x"] = 0
frame["y"] = layout_y
new_children.append(frame)
layout_y += 1000 # 900px height + 100px gap
# ─── Section 4: Feature Specs (y=12200) ───
spec_y = 12200
new_children.append(make_section_header("sec4_hdr", "4 — Feature Specs", 0, spec_y))
spec_y += 100
placed_ids = set()
for group_name, group_ids in FEATURE_GROUPS.items():
# Group label
new_children.append(make_subsection_label(
f"fg_{group_name.replace(' ', '_').replace('&','and').lower()[:20]}",
group_name.upper(), 0, spec_y
))
spec_y += 30
group_x = 0
max_h = 0
for fid in group_ids:
if fid in existing:
frame = existing.pop(fid)
fw = frame.get("width", 300)
if isinstance(fw, str):
fw = 340 # fallback for dynamic widths
# Wrap to next row if too wide
if group_x + fw > 3000:
spec_y += max_h + 40
group_x = 0
max_h = 0
frame["x"] = group_x
frame["y"] = spec_y
new_children.append(frame)
placed_ids.add(fid)
fh = frame.get("height", 200)
if isinstance(fh, str):
fh = 400 # fallback
max_h = max(max_h, fh)
group_x += fw + 100
spec_y += max_h + 80
# Any remaining unplaced frames go at the end
for fid, frame in existing.items():
if fid not in placed_ids:
frame["x"] = 0
frame["y"] = spec_y
new_children.append(frame)
fh = frame.get("height", 200)
if isinstance(fh, str):
fh = 400
spec_y += fh + 100
# 5. Replace children
data["children"] = new_children
# 6. Save
save(data)
# Stats
reusable_count = sum(1 for c in new_children if c.get("reusable"))
print(f"✅ Restructured ui-design.pen")
print(f" Total nodes: {len(new_children)}")
print(f" Reusable components: {reusable_count}")
print(f" Variables: {len(data['variables'])}")
print(f" Hardcoded colors replaced in existing frames")
print(f" Canvas organized into 5 sections")
if __name__ == "__main__":
transform()

285
src-tauri/Cargo.lock generated
View File

@@ -123,16 +123,6 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -619,15 +609,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "colored"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -663,16 +644,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -696,7 +667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
@@ -709,7 +680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation",
"libc",
]
@@ -1012,6 +983,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.6"
@@ -1695,25 +1672,6 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.13.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1865,11 +1823,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -1928,11 +1884,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2150,6 +2104,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -2256,36 +2219,6 @@ dependencies = [
"selectors",
]
[[package]]
name = "laputa"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"futures-util",
"gray_matter",
"log",
"mockito",
"regex",
"reqwest 0.12.28",
"sentry",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
"tempfile",
"tokio",
"uuid",
"walkdir",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2464,31 +2397,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mockito"
version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
dependencies = [
"assert-json-diff",
"bytes",
"colored",
"futures-core",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"log",
"pin-project-lite",
"rand 0.9.2",
"regex",
"serde_json",
"serde_urlencoded",
"similar",
"tokio",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -3449,16 +3357,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -3479,16 +3377,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3507,15 +3395,6 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -3646,21 +3525,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -3671,14 +3546,12 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
]
@@ -3717,7 +3590,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"wasm-streams",
"web-sys",
]
@@ -3873,7 +3746,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"jni",
"log",
@@ -4005,7 +3878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -4379,12 +4252,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "siphasher"
version = "0.3.11"
@@ -4504,6 +4371,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.115",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4563,27 +4451,6 @@ dependencies = [
"syn 2.0.115",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4605,7 +4472,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -4887,6 +4754,20 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-prevent-default"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6260061932cad80647a823d0c5a3633f4eec62160a8f57bad0ab82b537477ef2"
dependencies = [
"bitflags 2.11.0",
"itertools",
"serde",
"strum",
"tauri",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
@@ -5162,7 +5043,6 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"socket2",
"tokio-macros",
@@ -5213,6 +5093,35 @@ dependencies = [
"tokio",
]
[[package]]
name = "tolaria"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"futures-util",
"gray_matter",
"log",
"regex",
"sentry",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-prevent-default",
"tauri-plugin-process",
"tauri-plugin-updater",
"tempfile",
"tokio",
"uuid",
"walkdir",
]
[[package]]
name = "toml"
version = "0.5.11"
@@ -5783,19 +5692,6 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -6069,17 +5965,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
]
[[package]]
name = "windows-result"
version = "0.3.4"

View File

@@ -1,5 +1,5 @@
[package]
name = "laputa"
name = "tolaria"
version = "0.1.0"
description = "Personal knowledge and life management app"
authors = ["you"]
@@ -9,7 +9,7 @@ edition = "2021"
rust-version = "1.77.2"
[lib]
name = "laputa_lib"
name = "tolaria_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
@@ -25,7 +25,6 @@ tauri-plugin-log = "2"
gray_matter = "0.2"
walkdir = "2"
chrono = { version = "0.4", features = ["serde"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
futures-util = "0.3"
base64 = "0.22"
@@ -35,9 +34,9 @@ tauri-plugin-dialog = "2"
tauri-plugin-updater = "2.10.0"
tauri-plugin-process = "2.3.1"
tauri-plugin-opener = "2"
tauri-plugin-prevent-default = "4.0.4"
sentry = "0.37"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tempfile = "3"
mockito = "1"

View File

@@ -28077,7 +28077,7 @@ var TOOLS = [
},
{
name: "open_note",
description: "Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.",
description: "Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.",
inputSchema: {
type: "object",
properties: {
@@ -28088,7 +28088,7 @@ var TOOLS = [
},
{
name: "highlight_editor",
description: "Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.",
description: "Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.",
inputSchema: {
type: "object",
properties: {
@@ -28100,7 +28100,7 @@ var TOOLS = [
},
{
name: "refresh_vault",
description: "Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.",
description: "Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.",
inputSchema: {
type: "object",
properties: {
@@ -28134,7 +28134,7 @@ async function handleGetNote(args) {
function handleOpenNote(args) {
broadcastUiAction("vault_changed", { path: args.path });
broadcastUiAction("open_tab", { path: args.path });
return { content: [{ type: "text", text: `Opening ${args.path} in Laputa` }] };
return { content: [{ type: "text", text: `Opening ${args.path} in Tolaria` }] };
}
function handleHighlightEditor(args) {
broadcastUiAction("highlight", { element: args.element, path: args.path });
@@ -28145,7 +28145,7 @@ function handleRefreshVault(args) {
return { content: [{ type: "text", text: "Vault refresh triggered" }] };
}
var server = new Server(
{ name: "laputa-mcp-server", version: "0.3.0" },
{ name: "tolaria-mcp-server", version: "0.3.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -28169,7 +28169,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`Laputa MCP server running (vault: ${VAULT_PATH})`);
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`);
}
main().catch(console.error);
/*! Bundled license information:

View File

@@ -500,8 +500,8 @@
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.laputa;
PRODUCT_NAME = "laputa";
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria;
PRODUCT_NAME = Tolaria;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
@@ -531,8 +531,8 @@
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.laputa;
PRODUCT_NAME = "laputa";
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria;
PRODUCT_NAME = Tolaria;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;

View File

@@ -1,6 +1,6 @@
name: laputa
name: Tolaria
options:
bundleIdPrefix: club.refactoring.laputa
bundleIdPrefix: club.refactoring.tolaria
deploymentTarget:
iOS: 14.0
fileGroups: [../../src]
@@ -10,8 +10,8 @@ configs:
settingGroups:
app:
base:
PRODUCT_NAME: laputa
PRODUCT_BUNDLE_IDENTIFIER: club.refactoring.laputa
PRODUCT_NAME: Tolaria
PRODUCT_BUNDLE_IDENTIFIER: club.refactoring.tolaria
targetTemplates:
app:
type: application
@@ -85,4 +85,4 @@ targets:
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 115 KiB

463
src-tauri/src/ai_agents.rs Normal file
View File

@@ -0,0 +1,463 @@
use serde::{Deserialize, Serialize};
use std::io::BufRead;
use std::path::PathBuf;
use std::process::{Command, Stdio};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiAgentId {
ClaudeCode,
Codex,
}
#[derive(Debug, Clone, Serialize)]
pub struct AiAgentAvailability {
pub installed: bool,
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AiAgentsStatus {
pub claude_code: AiAgentAvailability,
pub codex: AiAgentAvailability,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind")]
pub enum AiAgentStreamEvent {
Init {
session_id: String,
},
TextDelta {
text: String,
},
ThinkingDelta {
text: String,
},
ToolStart {
tool_name: String,
tool_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
input: Option<String>,
},
ToolDone {
tool_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
output: Option<String>,
},
Error {
message: String,
},
Done,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AiAgentStreamRequest {
pub agent: AiAgentId,
pub message: String,
pub system_prompt: Option<String>,
pub vault_path: String,
}
pub fn get_ai_agents_status() -> AiAgentsStatus {
AiAgentsStatus {
claude_code: availability_from_claude(),
codex: availability_from_codex(),
}
}
pub fn run_ai_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
match request.agent {
AiAgentId::ClaudeCode => {
let mapped = crate::claude_cli::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
};
crate::claude_cli::run_agent_stream(mapped, |event| {
if let Some(mapped_event) = map_claude_event(event) {
emit(mapped_event);
}
})
}
AiAgentId::Codex => run_codex_agent_stream(request, emit),
}
}
fn availability_from_claude() -> AiAgentAvailability {
let status = crate::claude_cli::check_cli();
AiAgentAvailability {
installed: status.installed,
version: status.version,
}
}
fn availability_from_codex() -> AiAgentAvailability {
let binary = match find_codex_binary() {
Ok(binary) => binary,
Err(_) => {
return AiAgentAvailability {
installed: false,
version: None,
}
}
};
AiAgentAvailability {
installed: true,
version: version_for_binary(&binary),
}
}
fn version_for_binary(binary: &PathBuf) -> Option<String> {
Command::new(binary)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn find_codex_binary() -> Result<PathBuf, String> {
if let Some(binary) = find_codex_binary_on_path()? {
return Ok(binary);
}
if let Some(binary) = find_existing_binary(codex_binary_candidates()) {
return Ok(binary);
}
Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into())
}
fn find_codex_binary_on_path() -> Result<Option<PathBuf>, String> {
let output = Command::new("which")
.arg("codex")
.output()
.map_err(|error| format!("Failed to run `which codex`: {error}"))?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(Some(PathBuf::from(path)));
}
}
Ok(None)
}
fn codex_binary_candidates() -> Vec<PathBuf> {
let home = dirs::home_dir().unwrap_or_default();
vec![
home.join(".local/bin/codex"),
home.join(".npm/bin/codex"),
PathBuf::from("/usr/local/bin/codex"),
PathBuf::from("/opt/homebrew/bin/codex"),
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
]
}
fn find_existing_binary(candidates: Vec<PathBuf>) -> Option<PathBuf> {
candidates.into_iter().find(|candidate| candidate.exists())
}
fn run_codex_agent_stream<F>(request: AiAgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = find_codex_binary()?;
let args = build_codex_args(&request)?;
let prompt = build_codex_prompt(&request);
let mut command = Command::new(binary);
command
.args(args)
.arg(prompt)
.current_dir(&request.vault_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command
.spawn()
.map_err(|error| format!("Failed to spawn codex: {error}"))?;
let stdout = child.stdout.take().ok_or("No stdout handle")?;
let reader = std::io::BufReader::new(stdout);
let mut thread_id = String::new();
for line in reader.lines() {
let line = match line {
Ok(line) => line,
Err(error) => {
emit(AiAgentStreamEvent::Error {
message: format!("Read error: {error}"),
});
break;
}
};
if line.trim().is_empty() {
continue;
}
let json = match serde_json::from_str::<serde_json::Value>(&line) {
Ok(json) => json,
Err(_) => continue,
};
if let Some(id) = json["thread_id"].as_str() {
thread_id = id.to_string();
}
dispatch_codex_event(&json, &mut emit);
}
let stderr_output = child
.stderr
.take()
.and_then(|stderr| std::io::read_to_string(stderr).ok())
.unwrap_or_default();
let status = child
.wait()
.map_err(|error| format!("Wait failed: {error}"))?;
if !status.success() {
emit(AiAgentStreamEvent::Error {
message: format_codex_error(stderr_output, status.to_string()),
});
}
emit(AiAgentStreamEvent::Done);
Ok(thread_id)
}
fn build_codex_args(request: &AiAgentStreamRequest) -> Result<Vec<String>, String> {
let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js");
let mcp_server_path = mcp_server
.to_str()
.ok_or("Invalid MCP server path")?
.to_string();
Ok(vec![
"exec".into(),
"--json".into(),
"--dangerously-bypass-approvals-and-sandbox".into(),
"-C".into(),
request.vault_path.clone(),
"-c".into(),
r#"mcp_servers.tolaria.command="node""#.into(),
"-c".into(),
format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path),
"-c".into(),
format!(
r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#,
request.vault_path
),
])
}
fn build_codex_prompt(request: &AiAgentStreamRequest) -> String {
match request
.system_prompt
.as_ref()
.map(|prompt| prompt.trim())
.filter(|prompt| !prompt.is_empty())
{
Some(system_prompt) => format!(
"System instructions:\n{system_prompt}\n\nUser request:\n{}",
request.message
),
None => request.message.clone(),
}
}
fn dispatch_codex_event<F>(json: &serde_json::Value, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
match json["type"].as_str().unwrap_or_default() {
"thread.started" => {
if let Some(thread_id) = json["thread_id"].as_str() {
emit(AiAgentStreamEvent::Init {
session_id: thread_id.to_string(),
});
}
}
"item.started" => emit_codex_item_event(json, false, emit),
"item.completed" => emit_codex_item_event(json, true, emit),
_ => {}
}
}
fn emit_codex_item_event<F>(json: &serde_json::Value, completed: bool, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let item = &json["item"];
let item_type = item["type"].as_str().unwrap_or_default();
let item_id = item["id"].as_str().unwrap_or_default();
match item_type {
"command_execution" => {
if completed {
emit(AiAgentStreamEvent::ToolDone {
tool_id: item_id.to_string(),
output: item["aggregated_output"]
.as_str()
.map(|output| output.to_string()),
});
} else {
emit(AiAgentStreamEvent::ToolStart {
tool_name: "Bash".into(),
tool_id: item_id.to_string(),
input: item["command"]
.as_str()
.map(|command| serde_json::json!({ "command": command }).to_string()),
});
}
}
"agent_message" if completed => {
if let Some(text) = item["text"].as_str() {
emit(AiAgentStreamEvent::TextDelta {
text: text.to_string(),
});
}
}
_ => {}
}
}
fn format_codex_error(stderr_output: String, status: String) -> String {
let lower = stderr_output.to_ascii_lowercase();
if is_codex_auth_error(&lower) {
return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into();
}
if stderr_output.trim().is_empty() {
format!("codex exited with status {status}")
} else {
stderr_output.lines().take(3).collect::<Vec<_>>().join("\n")
}
}
fn is_codex_auth_error(lower: &str) -> bool {
["auth", "login", "sign in"]
.iter()
.any(|pattern| lower.contains(pattern))
}
fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAgentStreamEvent> {
match event {
crate::claude_cli::ClaudeStreamEvent::Init { session_id } => {
Some(AiAgentStreamEvent::Init { session_id })
}
crate::claude_cli::ClaudeStreamEvent::TextDelta { text } => {
Some(AiAgentStreamEvent::TextDelta { text })
}
crate::claude_cli::ClaudeStreamEvent::ThinkingDelta { text } => {
Some(AiAgentStreamEvent::ThinkingDelta { text })
}
crate::claude_cli::ClaudeStreamEvent::ToolStart {
tool_name,
tool_id,
input,
} => Some(AiAgentStreamEvent::ToolStart {
tool_name,
tool_id,
input,
}),
crate::claude_cli::ClaudeStreamEvent::ToolDone { tool_id, output } => {
Some(AiAgentStreamEvent::ToolDone { tool_id, output })
}
crate::claude_cli::ClaudeStreamEvent::Error { message } => {
Some(AiAgentStreamEvent::Error { message })
}
crate::claude_cli::ClaudeStreamEvent::Done
| crate::claude_cli::ClaudeStreamEvent::Result { .. } => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_status_contains_both_agents() {
let status = get_ai_agents_status();
assert!(matches!(status.claude_code.installed, true | false));
assert!(matches!(status.codex.installed, true | false));
}
#[test]
fn build_codex_prompt_keeps_system_prompt_first() {
let prompt = build_codex_prompt(&AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Rename the note".into(),
system_prompt: Some("Be concise".into()),
vault_path: "/tmp/vault".into(),
});
assert!(prompt.starts_with("System instructions:\nBe concise"));
assert!(prompt.contains("User request:\nRename the note"));
}
#[test]
fn dispatch_codex_command_events_maps_to_bash_events() {
let mut events = Vec::new();
let started = serde_json::json!({
"type": "item.started",
"item": {
"id": "item_1",
"type": "command_execution",
"command": "/bin/zsh -lc pwd"
}
});
let completed = serde_json::json!({
"type": "item.completed",
"item": {
"id": "item_1",
"type": "command_execution",
"aggregated_output": "/private/tmp\n"
}
});
dispatch_codex_event(&started, &mut |event| events.push(event));
dispatch_codex_event(&completed, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. }
if tool_name == "Bash" && tool_id == "item_1"
));
assert!(matches!(
&events[1],
AiAgentStreamEvent::ToolDone { tool_id, output }
if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n")
));
}
#[test]
fn dispatch_codex_agent_message_maps_to_text_delta() {
let mut events = Vec::new();
let completed = serde_json::json!({
"type": "item.completed",
"item": {
"id": "item_2",
"type": "agent_message",
"text": "All set"
}
});
dispatch_codex_event(&completed, &mut |event| events.push(event));
assert!(matches!(
&events[0],
AiAgentStreamEvent::TextDelta { text } if text == "All set"
));
}
}

View File

@@ -0,0 +1,179 @@
use serde::Serialize;
use tauri::{ipc::Channel, AppHandle, Runtime, Url};
use tauri_plugin_updater::UpdaterExt;
const RELEASES_BASE_URL: &str = "https://refactoringhq.github.io/tolaria";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppUpdateMetadata {
pub current_version: String,
pub version: String,
pub date: Option<String>,
pub body: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event", content = "data")]
pub enum AppUpdateDownloadEvent {
#[serde(rename_all = "camelCase")]
Started {
content_length: Option<u64>,
},
#[serde(rename_all = "camelCase")]
Progress {
chunk_length: usize,
},
Finished,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReleaseChannel {
Alpha,
Stable,
}
impl ReleaseChannel {
fn from_settings_value(value: Option<&str>) -> Self {
match crate::settings::effective_release_channel(value) {
"alpha" => Self::Alpha,
_ => Self::Stable,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Alpha => "alpha",
Self::Stable => "stable",
}
}
fn updater_endpoint(self) -> Result<Url, String> {
let endpoint = format!("{}/{}/latest.json", RELEASES_BASE_URL, self.as_str());
Url::parse(&endpoint).map_err(|e| format!("Invalid updater endpoint: {e}"))
}
}
fn build_updater<R: Runtime>(
app_handle: &AppHandle<R>,
release_channel: ReleaseChannel,
) -> Result<tauri_plugin_updater::Updater, String> {
app_handle
.updater_builder()
.endpoints(vec![release_channel.updater_endpoint()?])
.map_err(|e| format!("Failed to configure updater endpoint: {e}"))?
.build()
.map_err(|e| format!("Failed to build updater: {e}"))
}
fn to_update_metadata(update: tauri_plugin_updater::Update) -> AppUpdateMetadata {
AppUpdateMetadata {
current_version: update.current_version,
version: update.version,
date: update.date.map(|value| value.to_string()),
body: update.body,
}
}
pub async fn check_for_app_update<R: Runtime>(
app_handle: AppHandle<R>,
release_channel: Option<String>,
) -> Result<Option<AppUpdateMetadata>, String> {
let channel = ReleaseChannel::from_settings_value(release_channel.as_deref());
let updater = build_updater(&app_handle, channel)?;
let update = updater
.check()
.await
.map_err(|e| format!("Failed to check for updates: {e}"))?;
Ok(update.map(to_update_metadata))
}
pub async fn download_and_install_app_update<R: Runtime>(
app_handle: AppHandle<R>,
release_channel: Option<String>,
expected_version: String,
on_event: Channel<AppUpdateDownloadEvent>,
) -> Result<(), String> {
let channel = ReleaseChannel::from_settings_value(release_channel.as_deref());
let updater = build_updater(&app_handle, channel)?;
let update = updater
.check()
.await
.map_err(|e| format!("Failed to refresh update metadata: {e}"))?
.ok_or_else(|| "No update is currently available".to_string())?;
if update.version != expected_version {
return Err(format!(
"Expected update version {}, found {}",
expected_version, update.version
));
}
let mut started = false;
update
.download_and_install(
|chunk_length, content_length| {
if !started {
started = true;
let _ = on_event.send(AppUpdateDownloadEvent::Started { content_length });
}
let _ = on_event.send(AppUpdateDownloadEvent::Progress { chunk_length });
},
|| {
let _ = on_event.send(AppUpdateDownloadEvent::Finished);
},
)
.await
.map_err(|e| format!("Failed to download and install update: {e}"))
}
#[cfg(test)]
mod tests {
use super::ReleaseChannel;
#[test]
fn release_channel_defaults_to_stable() {
assert_eq!(
ReleaseChannel::from_settings_value(None),
ReleaseChannel::Stable
);
assert_eq!(
ReleaseChannel::from_settings_value(Some("stable")),
ReleaseChannel::Stable
);
assert_eq!(
ReleaseChannel::from_settings_value(Some("beta")),
ReleaseChannel::Stable
);
assert_eq!(
ReleaseChannel::from_settings_value(Some("invalid")),
ReleaseChannel::Stable
);
}
#[test]
fn release_channel_accepts_alpha() {
assert_eq!(
ReleaseChannel::from_settings_value(Some("alpha")),
ReleaseChannel::Alpha
);
assert_eq!(
ReleaseChannel::from_settings_value(Some(" alpha ")),
ReleaseChannel::Alpha
);
}
#[test]
fn release_channel_endpoints_match_expected_paths() {
assert_eq!(
ReleaseChannel::Alpha.updater_endpoint().unwrap().as_str(),
"https://refactoringhq.github.io/tolaria/alpha/latest.json"
);
assert_eq!(
ReleaseChannel::Stable.updater_endpoint().unwrap().as_str(),
"https://refactoringhq.github.io/tolaria/stable/latest.json"
);
}
}

View File

@@ -208,7 +208,7 @@ fn build_mcp_config(vault_path: &str) -> Result<String, String> {
let index_js = server_dir.join("index.js");
let config = serde_json::json!({
"mcpServers": {
"laputa": {
"tolaria": {
"command": "node",
"args": [index_js.to_string_lossy()],
"env": { "VAULT_PATH": vault_path }
@@ -508,9 +508,9 @@ mod tests {
fn build_mcp_config_is_valid_json() {
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap();
assert!(parsed["mcpServers"]["laputa"]["command"].is_string());
assert!(parsed["mcpServers"]["tolaria"]["command"].is_string());
assert_eq!(
parsed["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
parsed["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
"/tmp/test-vault"
);
}

View File

@@ -1,6 +1,51 @@
#[cfg(desktop)]
use crate::claude_cli::ClaudeStreamEvent;
use crate::ai_agents::{AiAgentStreamRequest, AiAgentsStatus};
use crate::claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus};
use crate::vault::VaultAiGuidanceStatus;
use super::expand_tilde;
#[cfg(desktop)]
type StreamEmitter<Event> = Box<dyn Fn(Event) + Send>;
#[cfg(desktop)]
async fn run_desktop_stream<Event, Request, Runner>(
app_handle: tauri::AppHandle,
event_name: &'static str,
request: Request,
runner: Runner,
) -> Result<String, String>
where
Event: serde::Serialize + Send + 'static,
Request: Send + 'static,
Runner: FnOnce(Request, StreamEmitter<Event>) -> Result<String, String> + Send + 'static,
{
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
runner(
request,
Box::new(move |event| {
let _ = app_handle.emit(event_name, &event);
}),
)
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[cfg(desktop)]
macro_rules! define_desktop_stream_command {
($name:ident, $request:ty, $event_name:literal, $runner:path) => {
#[tauri::command]
pub async fn $name(
app_handle: tauri::AppHandle,
request: $request,
) -> Result<String, String> {
run_desktop_stream(app_handle, $event_name, request, $runner).await
}
};
}
// ── Claude CLI commands (desktop) ───────────────────────────────────────────
@@ -12,35 +57,45 @@ pub fn check_claude_cli() -> ClaudeCliStatus {
#[cfg(desktop)]
#[tauri::command]
pub async fn stream_claude_chat(
app_handle: tauri::AppHandle,
request: ChatStreamRequest,
) -> Result<String, String> {
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
crate::claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
let _ = app_handle.emit("claude-stream", &event);
})
})
.await
.map_err(|e| format!("Task failed: {e}"))?
pub fn get_ai_agents_status() -> AiAgentsStatus {
crate::ai_agents::get_ai_agents_status()
}
#[tauri::command]
pub fn get_vault_ai_guidance_status(vault_path: String) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = expand_tilde(&vault_path);
crate::vault::get_ai_guidance_status(vault_path.as_ref())
}
#[tauri::command]
pub fn restore_vault_ai_guidance(vault_path: String) -> Result<VaultAiGuidanceStatus, String> {
let vault_path = expand_tilde(&vault_path);
crate::vault::restore_ai_guidance_files(vault_path.as_ref())
}
#[cfg(desktop)]
#[tauri::command]
pub async fn stream_claude_agent(
app_handle: tauri::AppHandle,
request: AgentStreamRequest,
) -> Result<String, String> {
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
crate::claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
let _ = app_handle.emit("claude-agent-stream", &event);
})
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
define_desktop_stream_command!(
stream_claude_chat,
ChatStreamRequest,
"claude-stream",
crate::claude_cli::run_chat_stream
);
#[cfg(desktop)]
define_desktop_stream_command!(
stream_claude_agent,
AgentStreamRequest,
"claude-agent-stream",
crate::claude_cli::run_agent_stream
);
#[cfg(desktop)]
define_desktop_stream_command!(
stream_ai_agent,
AiAgentStreamRequest,
"ai-agent-stream",
crate::ai_agents::run_ai_agent_stream
);
// ── Claude CLI (mobile stubs) ───────────────────────────────────────────────
@@ -53,6 +108,21 @@ pub fn check_claude_cli() -> ClaudeCliStatus {
}
}
#[cfg(mobile)]
#[tauri::command]
pub fn get_ai_agents_status() -> AiAgentsStatus {
AiAgentsStatus {
claude_code: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
codex: crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
}
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_claude_chat(
@@ -70,3 +140,12 @@ pub async fn stream_claude_agent(
) -> Result<String, String> {
Err("Claude CLI is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn stream_ai_agent(
_app_handle: tauri::AppHandle,
_request: AiAgentStreamRequest,
) -> Result<String, String> {
Err("CLI AI agents are not available on mobile".into())
}

View File

@@ -0,0 +1,14 @@
use crate::vault;
use super::expand_tilde;
#[tauri::command]
pub async fn batch_delete_notes_async(paths: Vec<String>) -> Result<Vec<String>, String> {
let expanded: Vec<String> = paths
.iter()
.map(|path| expand_tilde(path).into_owned())
.collect();
tokio::task::spawn_blocking(move || vault::batch_delete_notes(&expanded))
.await
.map_err(|e| format!("Task panicked: {e}"))?
}

View File

@@ -151,6 +151,13 @@ pub fn init_git_repo(vault_path: String) -> Result<(), String> {
crate::git::init_repo(&vault_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn clone_repo(url: String, local_path: String) -> Result<String, String> {
let local_path = expand_tilde(&local_path);
crate::git::clone_repo(&url, &local_path)
}
// ── Git commands (mobile stubs) ─────────────────────────────────────────────
#[cfg(mobile)]
@@ -271,3 +278,9 @@ pub fn is_git_repo(_vault_path: String) -> bool {
pub fn init_git_repo(_vault_path: String) -> Result<(), String> {
Err("Git init is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn clone_repo(_url: String, _local_path: String) -> Result<String, String> {
Err("Git clone is not available on mobile".into())
}

View File

@@ -1,88 +0,0 @@
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use super::expand_tilde;
// ── GitHub commands (desktop) ───────────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
crate::github::github_list_repos(&token).await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_create_repo(
token: String,
name: String,
private: bool,
) -> Result<GithubRepo, String> {
crate::github::github_create_repo(&token, &name, private).await
}
#[cfg(desktop)]
#[tauri::command]
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
let local_path = expand_tilde(&local_path);
crate::github::clone_repo(&url, &token, &local_path)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
crate::github::github_device_flow_start().await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
crate::github::github_device_flow_poll(&device_code).await
}
#[cfg(desktop)]
#[tauri::command]
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
crate::github::github_get_user(&token).await
}
// ── GitHub commands (mobile stubs) ──────────────────────────────────────────
#[cfg(mobile)]
#[tauri::command]
pub async fn github_list_repos(_token: String) -> Result<Vec<GithubRepo>, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_create_repo(
_token: String,
_name: String,
_private: bool,
) -> Result<GithubRepo, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn clone_repo(_url: String, _token: String, _local_path: String) -> Result<String, String> {
Err("Git clone is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_device_flow_poll(_device_code: String) -> Result<DeviceFlowPollResult, String> {
Err("GitHub integration is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub async fn github_get_user(_token: String) -> Result<GitHubUser, String> {
Err("GitHub integration is not available on mobile".into())
}

View File

@@ -1,39 +1,49 @@
mod ai;
mod delete;
mod git;
mod github;
mod system;
mod vault;
mod version;
use std::borrow::Cow;
pub use ai::*;
pub use delete::*;
pub use git::*;
pub use github::*;
pub use system::*;
pub use vault::*;
pub use version::*;
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
/// Returns the original string unchanged if it doesn't start with `~` or if the
/// home directory cannot be determined.
pub fn expand_tilde(path: &str) -> Cow<'_, str> {
if path == "~" {
if let Some(home) = dirs::home_dir() {
return Cow::Owned(home.to_string_lossy().into_owned());
}
} else if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return Cow::Owned(format!("{}/{}", home.to_string_lossy(), rest));
}
let Some(home) = dirs::home_dir() else {
return Cow::Borrowed(path);
};
match path {
"~" => Cow::Owned(home.to_string_lossy().into_owned()),
_ => path
.strip_prefix("~/")
.map(|rest| Cow::Owned(home.join(rest).to_string_lossy().into_owned()))
.unwrap_or(Cow::Borrowed(path)),
}
Cow::Borrowed(path)
}
pub fn parse_build_label(version: &str) -> String {
fn is_numeric_version_part(part: &str) -> bool {
!part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit())
}
fn is_legacy_build_version(minor: &str, patch: &str) -> bool {
minor.len() >= 6 && is_numeric_version_part(minor) && is_numeric_version_part(patch)
}
fn parse_legacy_build_label(version: &str) -> Option<String> {
let parts: Vec<&str> = version.split('.').collect();
match parts.as_slice() {
[_, minor, patch] if minor.len() >= 6 => format!("b{}", patch),
[_, _, _] => "dev".to_string(),
_ => "b?".to_string(),
[_, minor, patch] if is_legacy_build_version(minor, patch) => Some(format!("b{}", patch)),
_ => None,
}
}
@@ -72,22 +82,4 @@ mod tests {
let result = expand_tilde("/home/~user/path");
assert_eq!(result, "/home/~user/path");
}
#[test]
fn parse_build_label_release_version() {
assert_eq!(parse_build_label("0.20260303.281"), "b281");
assert_eq!(parse_build_label("0.20251215.42"), "b42");
}
#[test]
fn parse_build_label_dev_version() {
assert_eq!(parse_build_label("0.1.0"), "dev");
assert_eq!(parse_build_label("0.0.0"), "dev");
}
#[test]
fn parse_build_label_malformed() {
assert_eq!(parse_build_label("invalid"), "b?");
assert_eq!(parse_build_label(""), "b?");
}
}

View File

@@ -3,6 +3,13 @@ use crate::menu;
use crate::settings::Settings;
use crate::vault_list;
use crate::vault_list::VaultList;
use serde::Deserialize;
#[cfg(desktop)]
use tauri::ipc::Channel;
#[cfg(desktop)]
use tauri::LogicalSize;
#[cfg(desktop)]
use tauri::Window;
use super::parse_build_label;
@@ -41,23 +48,29 @@ pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
// ── Menu commands ───────────────────────────────────────────────────────────
#[cfg(desktop)]
#[tauri::command]
pub fn update_menu_state(
app_handle: tauri::AppHandle,
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MenuStateUpdate {
has_active_note: bool,
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
has_restorable_deleted_note: Option<bool>,
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_menu_state(
app_handle: tauri::AppHandle,
state: MenuStateUpdate,
) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
if let Some(v) = has_modified_files {
menu::set_note_items_enabled(&app_handle, state.has_active_note);
if let Some(v) = state.has_modified_files {
menu::set_git_commit_items_enabled(&app_handle, v);
}
if let Some(v) = has_conflicts {
if let Some(v) = state.has_conflicts {
menu::set_git_conflict_items_enabled(&app_handle, v);
}
if let Some(v) = has_restorable_deleted_note {
if let Some(v) = state.has_restorable_deleted_note {
menu::set_restore_deleted_item_enabled(&app_handle, v);
}
Ok(())
@@ -67,10 +80,63 @@ pub fn update_menu_state(
#[tauri::command]
pub fn update_menu_state(
_app_handle: tauri::AppHandle,
_has_active_note: bool,
_has_modified_files: Option<bool>,
_has_conflicts: Option<bool>,
_has_restorable_deleted_note: Option<bool>,
_state: MenuStateUpdate,
) -> Result<(), String> {
Ok(())
}
#[cfg(desktop)]
#[tauri::command]
pub fn trigger_menu_command(app_handle: tauri::AppHandle, id: String) -> Result<(), String> {
menu::emit_custom_menu_event(&app_handle, &id)
}
#[cfg(mobile)]
#[tauri::command]
pub fn trigger_menu_command(_app_handle: tauri::AppHandle, _id: String) -> Result<(), String> {
Err("Native menu commands are not available on mobile".into())
}
#[cfg(desktop)]
#[tauri::command]
pub fn update_current_window_min_size(
window: Window,
min_width: f64,
min_height: f64,
grow_to_fit: bool,
) -> Result<(), String> {
window
.set_min_size(Some(LogicalSize::new(min_width, min_height)))
.map_err(|e| e.to_string())?;
if !grow_to_fit {
return Ok(());
}
let scale_factor = window.scale_factor().map_err(|e| e.to_string())?;
let current_size = window
.inner_size()
.map_err(|e| e.to_string())?
.to_logical::<f64>(scale_factor);
let next_width = current_size.width.max(min_width);
let next_height = current_size.height.max(min_height);
if next_width == current_size.width && next_height == current_size.height {
return Ok(());
}
window
.set_size(LogicalSize::new(next_width, next_height))
.map_err(|e| e.to_string())
}
#[cfg(mobile)]
#[tauri::command]
pub fn update_current_window_min_size(
_window: tauri::Window,
_min_width: f64,
_min_height: f64,
_grow_to_fit: bool,
) -> Result<(), String> {
Ok(())
}
@@ -93,6 +159,52 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
crate::settings::save_settings(settings)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn check_for_app_update(
app_handle: tauri::AppHandle,
release_channel: Option<String>,
) -> Result<Option<crate::app_updater::AppUpdateMetadata>, String> {
crate::app_updater::check_for_app_update(app_handle, release_channel).await
}
#[cfg(mobile)]
#[tauri::command]
pub async fn check_for_app_update(
_app_handle: tauri::AppHandle,
_release_channel: Option<String>,
) -> Result<Option<crate::app_updater::AppUpdateMetadata>, String> {
Ok(None)
}
#[cfg(desktop)]
#[tauri::command]
pub async fn download_and_install_app_update(
app_handle: tauri::AppHandle,
release_channel: Option<String>,
expected_version: String,
on_event: Channel<crate::app_updater::AppUpdateDownloadEvent>,
) -> Result<(), String> {
crate::app_updater::download_and_install_app_update(
app_handle,
release_channel,
expected_version,
on_event,
)
.await
}
#[cfg(mobile)]
#[tauri::command]
pub async fn download_and_install_app_update(
_app_handle: tauri::AppHandle,
_release_channel: Option<String>,
_expected_version: String,
_on_event: tauri::ipc::Channel<crate::app_updater::AppUpdateDownloadEvent>,
) -> Result<(), String> {
Err("App updates are not available on mobile".into())
}
#[tauri::command]
pub fn reinit_telemetry() {
crate::telemetry::reinit_sentry();

View File

@@ -102,40 +102,62 @@ pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
#[tauri::command]
pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let folder_path = std::path::Path::new(vault_path.as_ref()).join(&folder_name);
let folder_path =
build_vault_folder_path(std::path::Path::new(vault_path.as_ref()), &folder_name);
ensure_missing_folder(&folder_path, &folder_name)?;
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name)
}
fn build_vault_folder_path(vault_root: &std::path::Path, folder_name: &str) -> std::path::PathBuf {
vault_root.join(folder_name)
}
fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Result<(), String> {
if folder_path.exists() {
return Err(format!("Folder '{}' already exists", folder_name));
}
std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(folder_name)
Ok(())
}
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
git::init_repo(vault_path)?;
vault::seed_config_files(vault_path);
Ok(())
}
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string()
}
#[tauri::command]
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
let path = expand_tilde(&target_path).into_owned();
let vault_dir = std::path::Path::new(&path);
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
crate::git::init_repo(&path)?;
Ok(vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string())
initialize_empty_vault(vault_dir, &path)?;
Ok(canonical_vault_path_string(vault_dir))
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = match target_path {
Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(),
_ => vault::default_vault_path()?.to_string_lossy().to_string(),
};
let path = resolve_getting_started_target(target_path.as_deref())?;
vault::create_getting_started_vault(&path)
}
fn resolve_getting_started_target(target_path: Option<&str>) -> Result<String, String> {
match target_path {
Some(path) if !path.is_empty() => Ok(expand_tilde(path).into_owned()),
_ => vault::default_vault_path().map(|path| path.to_string_lossy().to_string()),
}
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
@@ -409,4 +431,20 @@ mod tests {
assert!(vault_path.join("config.md").exists());
assert!(vault_path.join(".gitignore").exists());
}
#[test]
fn test_create_empty_vault_seeds_agents_and_config() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("fresh-vault");
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
assert!(result.is_ok());
assert!(vault_path.join(".git").exists());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("config.md").exists());
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
assert!(agents.contains("views/*.yml"));
}
}

View File

@@ -0,0 +1,121 @@
use super::{is_numeric_version_part, parse_legacy_build_label};
pub fn parse_build_label(version: &str) -> String {
let version = version.trim();
if version.is_empty() {
return "b?".to_string();
}
parse_legacy_build_label(version)
.or_else(|| parse_calendar_build_label(version))
.or_else(|| parse_semver_build_label(version))
.unwrap_or_else(|| "b?".to_string())
}
fn strip_build_metadata(version: &str) -> &str {
version.split_once('+').map_or(version, |(base, _)| base)
}
fn parse_calendar_build_label(version: &str) -> Option<String> {
let semver = strip_build_metadata(version);
let (core, prerelease) = semver
.split_once('-')
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
let [year, month, day] = split_numeric_version_parts(core)?;
if year.len() != 4 {
return None;
}
let core_version = format!(
"{}.{}.{}",
year.parse::<u32>().ok()?,
month.parse::<u32>().ok()?,
day.parse::<u32>().ok()?
);
match prerelease {
Some(suffix) if suffix.starts_with("alpha.") => suffix
.strip_prefix("alpha.")
.map(|sequence| format!("Alpha {}.{}", core_version, sequence)),
Some(suffix) if suffix.starts_with("stable.") => Some(core_version),
Some(_) => None,
None => Some(core_version),
}
}
fn parse_semver_build_label(version: &str) -> Option<String> {
let semver = strip_build_metadata(version);
let (core, prerelease) = semver
.split_once('-')
.map_or((semver, None), |(base, suffix)| (base, Some(suffix)));
split_numeric_version_parts(core)?;
match prerelease {
Some(suffix) if suffix.starts_with("alpha.") => Some(format!("Alpha {}", semver)),
Some(_) => Some(format!("v{}", semver)),
None if semver == "0.1.0" || semver == "0.0.0" => Some("dev".to_string()),
None => Some(format!("v{}", semver)),
}
}
fn split_numeric_version_parts(version: &str) -> Option<[&str; 3]> {
let parts: Vec<&str> = version.split('.').collect();
let [major, minor, patch] = parts.as_slice() else {
return None;
};
if ![major, minor, patch]
.iter()
.all(|part| is_numeric_version_part(part))
{
return None;
}
Some([major, minor, patch])
}
#[cfg(test)]
mod tests {
use super::parse_build_label;
#[test]
fn parse_build_label_release_version() {
assert_eq!(parse_build_label("0.20260303.281"), "b281");
assert_eq!(parse_build_label("0.20251215.42"), "b42");
}
#[test]
fn parse_build_label_calendar_versions() {
assert_eq!(parse_build_label("2026.4.16"), "2026.4.16");
assert_eq!(parse_build_label("2026.4.16-stable.1"), "2026.4.16");
assert_eq!(parse_build_label("2026.4.16-alpha.3"), "Alpha 2026.4.16.3");
assert_eq!(
parse_build_label("2026.4.16-alpha.3+darwin"),
"Alpha 2026.4.16.3"
);
}
#[test]
fn parse_build_label_legacy_semver_releases() {
assert_eq!(parse_build_label("1.2.3"), "v1.2.3");
assert_eq!(
parse_build_label("1.2.4-alpha.202604122135.7"),
"Alpha 1.2.4-alpha.202604122135.7"
);
assert_eq!(
parse_build_label("1.2.4-alpha.202604122135.7+darwin"),
"Alpha 1.2.4-alpha.202604122135.7"
);
}
#[test]
fn parse_build_label_dev_version() {
assert_eq!(parse_build_label("0.1.0"), "dev");
assert_eq!(parse_build_label("0.0.0"), "dev");
}
#[test]
fn parse_build_label_malformed() {
assert_eq!(parse_build_label("invalid"), "b?");
assert_eq!(parse_build_label(""), "b?");
}
}

View File

@@ -1,4 +1,6 @@
mod ops;
#[cfg(test)]
mod ops_update_tests;
mod yaml;
use std::fs;
@@ -132,32 +134,36 @@ mod tests {
}
#[test]
fn test_update_frontmatter_block_scalar_add() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\n## Timeline";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
assert!(updated.contains("template: |"));
assert!(updated.contains(" ## Objective"));
assert!(updated.contains("type: Type"));
}
fn test_update_frontmatter_block_scalar_writes_and_rewrites() {
let cases = [
(
"---\ntype: Type\n---\n# Project\n",
"## Objective\n\n## Timeline",
&["template: |", " ## Objective", "type: Type"][..],
&[][..],
),
(
"---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n",
"## New\n\n## Content",
&[" ## New", "color: green"][..],
&["## Old"][..],
),
];
#[test]
fn test_update_frontmatter_block_scalar_replace() {
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String("## New\n\n## Content".to_string())),
)
.unwrap();
assert!(updated.contains(" ## New"));
assert!(!updated.contains("## Old"));
assert!(updated.contains("color: green"));
for (content, template, expected_present, expected_absent) in cases {
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
for expected in expected_present {
assert!(updated.contains(expected));
}
for unexpected in expected_absent {
assert!(!updated.contains(unexpected));
}
}
}
#[test]

View File

@@ -1,85 +1,152 @@
use super::yaml::{format_yaml_field, FrontmatterValue};
/// Check if a line defines a specific key (handles quoted and unquoted keys)
fn line_is_key(line: &str, key: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with(key) && trimmed[key.len()..].starts_with(':') {
return true;
}
let dq = format!("\"{}\":", key);
if trimmed.starts_with(&dq) {
return true;
}
let sq = format!("'{}\':", key);
if trimmed.starts_with(&sq) {
return true;
}
false
}
/// Check if a line continues the previous key's value (indented list item,
/// block scalar content, or blank line inside a block scalar).
fn is_value_continuation(line: &str) -> bool {
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
fn is_value_continuation(line: FrontmatterLine<'_>) -> bool {
line.0.is_empty() || line.0.starts_with(" ") || line.0.starts_with('\t')
}
/// Split content into frontmatter body and the rest after the closing `---`.
/// Returns `(fm_content, rest)` where `fm_content` is between the opening and closing `---`.
fn split_frontmatter(content: &str) -> Result<(&str, &str), String> {
let after_open = &content[4..];
// Handle empty frontmatter: closing --- immediately after opening ---\n
if let Some(stripped) = after_open.strip_prefix("---") {
return Ok(("", stripped));
#[derive(Clone, Copy)]
enum SystemKey {
Icon,
Order,
SidebarLabel,
Sort,
}
impl SystemKey {
fn canonical(self) -> &'static str {
match self {
Self::Icon => "_icon",
Self::Order => "_order",
Self::SidebarLabel => "_sidebar_label",
Self::Sort => "_sort",
}
}
fn legacy_aliases(self) -> &'static [&'static str] {
match self {
Self::Icon => &["icon"],
Self::Order => &["order"],
Self::SidebarLabel => &["sidebar_label", "sidebar label"],
Self::Sort => &["sort"],
}
}
let fm_end = after_open
.find("\n---")
.map(|i| i + 4)
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
Ok((&content[4..fm_end], &content[fm_end + 4..]))
}
/// Wrap content in a new frontmatter block containing a single field.
fn prepend_new_frontmatter(content: &str, key: &str, value: &FrontmatterValue) -> String {
let field_lines = format_yaml_field(key, value);
format!("---\n{}\n---\n{}", field_lines.join("\n"), content)
#[derive(Clone, Copy)]
struct DocumentText<'a>(&'a str);
#[derive(Clone, Copy)]
struct FrontmatterLine<'a>(&'a str);
#[derive(Clone, Copy)]
struct PropertyKey<'a>(&'a str);
impl<'a> PropertyKey<'a> {
fn as_str(self) -> &'a str {
self.0
}
}
/// Apply a field update to existing frontmatter lines.
/// Replaces the matching key (and its list continuations) with the new value,
/// or appends if the key is not found. If `value` is None, removes the key.
fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue>) -> Vec<String> {
let mut new_lines: Vec<String> = Vec::new();
let mut found_key = false;
let mut i = 0;
#[derive(Clone, Copy)]
struct FieldUpdate<'a> {
key: PropertyKey<'a>,
value: Option<&'a FrontmatterValue>,
}
while i < lines.len() {
if !line_is_key(lines[i], key) {
new_lines.push(lines[i].to_string());
impl<'a> FieldUpdate<'a> {
fn matches_line(self, line: FrontmatterLine<'_>) -> bool {
let trimmed = line.0.trim_start();
if trimmed.starts_with(self.key.as_str())
&& trimmed[self.key.as_str().len()..].starts_with(':')
{
return true;
}
let double_quoted = format!("\"{}\":", self.key.as_str());
if trimmed.starts_with(&double_quoted) {
return true;
}
let single_quoted = format!("'{}\':", self.key.as_str());
trimmed.starts_with(&single_quoted)
}
fn prepend_to(self, content: DocumentText<'_>) -> String {
let field_lines =
format_yaml_field(self.key.as_str(), self.value.expect("value must exist"));
format!("---\n{}\n---\n{}", field_lines.join("\n"), content.0)
}
fn apply_to_lines(self, lines: &[FrontmatterLine<'_>]) -> Vec<String> {
let mut new_lines: Vec<String> = Vec::new();
let mut found_key = false;
let mut i = 0;
while i < lines.len() {
if !self.matches_line(lines[i]) {
new_lines.push(lines[i].0.to_string());
i += 1;
continue;
}
found_key = true;
i += 1;
continue;
while i < lines.len() && is_value_continuation(lines[i]) {
i += 1;
}
if let Some(v) = self.value {
new_lines.extend(format_yaml_field(self.key.as_str(), v));
}
}
found_key = true;
i += 1;
// Skip continuation lines belonging to this key (lists, block scalars)
while i < lines.len() && is_value_continuation(lines[i]) {
i += 1;
}
// Insert replacement value (if any)
if let Some(v) = value {
new_lines.extend(format_yaml_field(key, v));
if let (false, Some(v)) = (found_key, self.value) {
new_lines.extend(format_yaml_field(self.key.as_str(), v));
}
new_lines
}
if let (false, Some(v)) = (found_key, value) {
new_lines.extend(format_yaml_field(key, v));
}
fn apply_to_content(self, content: DocumentText<'_>) -> Result<String, String> {
if !content.0.starts_with("---\n") {
return match self.value {
Some(_) => Ok(self.prepend_to(content)),
None => Ok(content.0.to_string()),
};
}
new_lines
let after_open = &content.0[4..];
let (fm_content, rest) = if let Some(stripped) = after_open.strip_prefix("---") {
("", stripped)
} else {
let fm_end = after_open
.find("\n---")
.map(|i| i + 4)
.ok_or_else(|| "Malformed frontmatter: no closing ---".to_string())?;
(&content.0[4..fm_end], &content.0[fm_end + 4..])
};
let lines: Vec<FrontmatterLine<'_>> = fm_content.lines().map(FrontmatterLine).collect();
let new_fm = self.apply_to_lines(&lines).join("\n");
Ok(format!("---\n{}\n---{}", new_fm, rest))
}
}
fn canonical_system_key(key: PropertyKey<'_>) -> Option<SystemKey> {
match key
.as_str()
.trim()
.to_ascii_lowercase()
.replace(' ', "_")
.as_str()
{
"_icon" | "icon" => Some(SystemKey::Icon),
"_order" | "order" => Some(SystemKey::Order),
"_sidebar_label" | "sidebar_label" | "sidebar label" => Some(SystemKey::SidebarLabel),
"_sort" | "sort" => Some(SystemKey::Sort),
_ => None,
}
}
/// Internal function to update frontmatter content
@@ -88,244 +155,26 @@ pub fn update_frontmatter_content(
key: &str,
value: Option<FrontmatterValue>,
) -> Result<String, String> {
if !content.starts_with("---\n") {
return match value {
Some(v) => Ok(prepend_new_frontmatter(content, key, &v)),
None => Ok(content.to_string()),
};
let update = FieldUpdate {
key: PropertyKey(key),
value: value.as_ref(),
};
let Some(system_key) = canonical_system_key(update.key) else {
return update.apply_to_content(DocumentText(content));
};
let mut updated = content.to_string();
for alias in system_key.legacy_aliases() {
updated = FieldUpdate {
key: PropertyKey(alias),
value: None,
}
.apply_to_content(DocumentText(&updated))?;
}
let (fm_content, rest) = split_frontmatter(content)?;
let lines: Vec<&str> = fm_content.lines().collect();
let new_lines = apply_field_update(&lines, key, value.as_ref());
let new_fm = new_lines.join("\n");
Ok(format!("---\n{}\n---{}", new_fm, rest))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_update_frontmatter_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
assert!(updated.contains("Status: Active"));
assert!(!updated.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_add_new_key() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(updated.contains("Owner: Luca"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_quoted_key() {
let content = "---\n\"Is A\": Note\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Is A",
Some(FrontmatterValue::String("Project".to_string())),
)
.unwrap();
assert!(updated.contains("\"Is A\": Project"));
assert!(!updated.contains("Note"));
}
#[test]
fn test_update_frontmatter_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec![
"Alias1".to_string(),
"Alias2".to_string(),
])),
)
.unwrap();
assert!(updated.contains("aliases:"));
assert!(updated.contains(" - \"Alias1\""));
assert!(updated.contains(" - \"Alias2\""));
}
#[test]
fn test_update_frontmatter_replace_list() {
let content = "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec!["New1".to_string()])),
)
.unwrap();
assert!(updated.contains(" - \"New1\""));
assert!(!updated.contains("Old1"));
assert!(!updated.contains("Old2"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_delete_frontmatter_property() {
let content = "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n";
let updated = update_frontmatter_content(content, "Owner", None).unwrap();
assert!(!updated.contains("Owner"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_delete_frontmatter_list_property() {
let content = "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "aliases", None).unwrap();
assert!(!updated.contains("aliases"));
assert!(!updated.contains("Alias1"));
assert!(updated.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_no_existing() {
let content = "# Test\n\nSome content here.";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Draft".to_string())),
)
.unwrap();
assert!(updated.starts_with("---\n"));
assert!(updated.contains("Status: Draft"));
assert!(updated.contains("# Test"));
}
#[test]
fn test_update_frontmatter_bool() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "Reviewed", Some(FrontmatterValue::Bool(true)))
.unwrap();
assert!(updated.contains("Reviewed: true"));
}
#[test]
fn test_update_frontmatter_number() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "Priority", Some(FrontmatterValue::Number(5.0)))
.unwrap();
assert!(updated.contains("Priority: 5"));
}
#[test]
fn test_update_frontmatter_number_float() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "Score", Some(FrontmatterValue::Number(9.5)))
.unwrap();
assert!(updated.contains("Score: 9.5"));
}
#[test]
fn test_update_frontmatter_null() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "ClearMe", Some(FrontmatterValue::Null)).unwrap();
assert!(updated.contains("ClearMe: null"));
}
#[test]
fn test_update_frontmatter_empty_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated =
update_frontmatter_content(content, "tags", Some(FrontmatterValue::List(vec![])))
.unwrap();
assert!(updated.contains("tags: []"));
}
#[test]
fn test_update_frontmatter_malformed_no_closing_fence() {
let content = "---\nStatus: Draft\nNo closing fence here";
let result = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Malformed frontmatter"));
}
#[test]
fn test_delete_nonexistent_key_noop() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
assert_eq!(updated, content);
}
#[test]
fn test_delete_from_no_frontmatter_noop() {
let content = "# Test\n\nSome content.";
let updated = update_frontmatter_content(content, "NonExistent", None).unwrap();
assert_eq!(updated, content);
}
#[test]
fn test_line_is_key_unquoted() {
assert!(line_is_key("Status: Draft", "Status"));
assert!(!line_is_key("Status: Draft", "Owner"));
}
#[test]
fn test_line_is_key_double_quoted() {
assert!(line_is_key("\"Is A\": Note", "Is A"));
assert!(!line_is_key("\"Is A\": Note", "Status"));
}
#[test]
fn test_line_is_key_single_quoted() {
assert!(line_is_key("'Is A': Note", "Is A"));
}
#[test]
fn test_line_is_key_leading_whitespace() {
assert!(line_is_key(" Status: Draft", "Status"));
}
#[test]
fn test_line_is_key_partial_match() {
assert!(!line_is_key("StatusBar: value", "Status"));
}
#[test]
fn test_split_frontmatter_empty_block() {
let result = split_frontmatter("---\n---\n");
assert!(result.is_ok());
let (fm, rest) = result.unwrap();
assert_eq!(fm, "");
assert_eq!(rest, "\n");
}
#[test]
fn test_split_frontmatter_empty_block_no_trailing_newline() {
let result = split_frontmatter("---\n---");
assert!(result.is_ok());
}
#[test]
fn test_split_frontmatter_empty_block_with_body() {
let result = split_frontmatter("---\n---\n\n# Title\n");
assert!(result.is_ok());
let (fm, rest) = result.unwrap();
assert_eq!(fm, "");
assert!(rest.contains("# Title"));
}
FieldUpdate {
key: PropertyKey(system_key.canonical()),
value: update.value,
}
.apply_to_content(DocumentText(&updated))
}

View File

@@ -0,0 +1,192 @@
use super::{update_frontmatter_content, FrontmatterValue};
struct UpdateCase<'a> {
content: &'a str,
key: &'a str,
value: Option<FrontmatterValue>,
expected_present: &'a [&'a str],
expected_absent: &'a [&'a str],
}
fn assert_updated_content(case: UpdateCase<'_>) {
let updated = update_frontmatter_content(case.content, case.key, case.value).unwrap();
for expected in case.expected_present {
assert!(
updated.contains(expected),
"missing expected snippet: {expected}"
);
}
for unexpected in case.expected_absent {
assert!(
!updated.contains(unexpected),
"found unexpected snippet: {unexpected}"
);
}
}
#[test]
fn test_update_frontmatter_replaces_or_adds_scalar_fields() {
let cases = [
UpdateCase {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "Status",
value: Some(FrontmatterValue::String("Active".to_string())),
expected_present: &["Status: Active"],
expected_absent: &["Status: Draft"],
},
UpdateCase {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "Owner",
value: Some(FrontmatterValue::String("Luca".to_string())),
expected_present: &["Owner: Luca", "Status: Draft"],
expected_absent: &[],
},
UpdateCase {
content: "---\n\"Is A\": Note\n---\n# Test\n",
key: "Is A",
value: Some(FrontmatterValue::String("Project".to_string())),
expected_present: &["\"Is A\": Project"],
expected_absent: &["\"Is A\": Note"],
},
UpdateCase {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "Reviewed",
value: Some(FrontmatterValue::Bool(true)),
expected_present: &["Reviewed: true"],
expected_absent: &[],
},
UpdateCase {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "Priority",
value: Some(FrontmatterValue::Number(5.0)),
expected_present: &["Priority: 5"],
expected_absent: &[],
},
UpdateCase {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "Score",
value: Some(FrontmatterValue::Number(9.5)),
expected_present: &["Score: 9.5"],
expected_absent: &[],
},
UpdateCase {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "ClearMe",
value: Some(FrontmatterValue::Null),
expected_present: &["ClearMe: null"],
expected_absent: &[],
},
];
for case in cases {
assert_updated_content(case);
}
}
#[test]
fn test_update_frontmatter_list_and_delete_paths() {
let list_cases = [
UpdateCase {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "aliases",
value: Some(FrontmatterValue::List(vec![
"Alias1".to_string(),
"Alias2".to_string(),
])),
expected_present: &["aliases:", " - \"Alias1\"", " - \"Alias2\""],
expected_absent: &[],
},
UpdateCase {
content: "---\naliases:\n - Old1\n - Old2\nStatus: Draft\n---\n# Test\n",
key: "aliases",
value: Some(FrontmatterValue::List(vec!["New1".to_string()])),
expected_present: &[" - \"New1\"", "Status: Draft"],
expected_absent: &["Old1", "Old2"],
},
UpdateCase {
content: "---\naliases:\n - Alias1\n - Alias2\nStatus: Draft\n---\n# Test\n",
key: "aliases",
value: None,
expected_present: &["Status: Draft"],
expected_absent: &["aliases", "Alias1"],
},
UpdateCase {
content: "---\nStatus: Draft\nOwner: Luca\n---\n# Test\n",
key: "Owner",
value: None,
expected_present: &["Status: Draft"],
expected_absent: &["Owner"],
},
UpdateCase {
content: "---\nStatus: Draft\n---\n# Test\n",
key: "tags",
value: Some(FrontmatterValue::List(vec![])),
expected_present: &["tags: []"],
expected_absent: &[],
},
];
for case in list_cases {
assert_updated_content(case);
}
}
#[test]
fn test_update_frontmatter_handles_missing_or_malformed_frontmatter() {
let inserted = update_frontmatter_content(
"# Test\n\nSome content here.",
"Status",
Some(FrontmatterValue::String("Draft".to_string())),
)
.unwrap();
assert!(inserted.starts_with("---\n"));
assert!(inserted.contains("Status: Draft"));
assert!(inserted.contains("# Test"));
let malformed = update_frontmatter_content(
"---\nStatus: Draft\nNo closing fence here",
"Status",
Some(FrontmatterValue::String("Active".to_string())),
);
assert!(malformed.is_err());
assert!(malformed.unwrap_err().contains("Malformed frontmatter"));
let unchanged =
update_frontmatter_content("---\nStatus: Draft\n---\n# Test\n", "Missing", None).unwrap();
assert_eq!(unchanged, "---\nStatus: Draft\n---\n# Test\n");
let no_frontmatter =
update_frontmatter_content("# Test\n\nSome content.", "Missing", None).unwrap();
assert_eq!(no_frontmatter, "# Test\n\nSome content.");
}
#[test]
fn test_update_frontmatter_canonicalizes_system_metadata_keys() {
let cases = [
UpdateCase {
content: "---\nicon: rocket\n---\n# Test\n",
key: "icon",
value: Some(FrontmatterValue::String("star".to_string())),
expected_present: &["_icon: star"],
expected_absent: &["\nicon:", "rocket"],
},
UpdateCase {
content: "---\nsidebar label: Projects\nsidebar_label: Legacy\n---\n# Test\n",
key: "_sidebar_label",
value: Some(FrontmatterValue::String("Programs".to_string())),
expected_present: &["_sidebar_label: Programs"],
expected_absent: &["sidebar label: Projects", "sidebar_label: Legacy"],
},
UpdateCase {
content: "---\nsort: modified:desc\n_sort: title:asc\n---\n# Test\n",
key: "_sort",
value: None,
expected_present: &["# Test"],
expected_absent: &["\nsort:", "\n_sort:"],
},
];
for case in cases {
assert_updated_content(case);
}
}

163
src-tauri/src/git/clone.rs Normal file
View File

@@ -0,0 +1,163 @@
use std::path::Path;
use std::process::Command;
/// Clone a git repository to a local path using the system git configuration.
pub fn clone_repo(url: &str, local_path: &str) -> Result<String, String> {
let dest = Path::new(local_path);
prepare_clone_destination(dest)?;
if let Err(err) = run_clone(url, dest) {
cleanup_failed_clone(dest);
return Err(err);
}
Ok(format!("Cloned to {}", dest.display()))
}
fn prepare_clone_destination(dest: &Path) -> Result<(), String> {
if !dest.exists() {
return ensure_parent_directory(dest);
}
ensure_empty_directory(dest)
}
fn ensure_empty_directory(dest: &Path) -> Result<(), String> {
if !dest.is_dir() {
return Err(format!(
"Destination '{}' already exists and is not a directory",
dest.display()
));
}
if directory_has_entries(dest)? {
return Err(format!(
"Destination '{}' already exists and is not empty",
dest.display()
));
}
Ok(())
}
fn ensure_parent_directory(dest: &Path) -> Result<(), String> {
let Some(parent) = dest.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() {
return Ok(());
}
std::fs::create_dir_all(parent).map_err(|e| {
format!(
"Failed to create parent directory for '{}': {}",
dest.display(),
e
)
})
}
fn directory_has_entries(dest: &Path) -> Result<bool, String> {
dest.read_dir()
.map_err(|e| format!("Failed to inspect destination '{}': {}", dest.display(), e))
.map(|mut entries| entries.next().is_some())
}
fn run_clone(url: &str, dest: &Path) -> Result<(), String> {
let destination = dest
.to_str()
.ok_or_else(|| format!("Destination '{}' is not valid UTF-8", dest.display()))?;
let output = Command::new("git")
.args(["clone", "--progress", url, destination])
.output()
.map_err(|e| format!("Failed to run git clone: {}", e))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("git clone failed: {}", stderr.trim()))
}
fn cleanup_failed_clone(dest: &Path) {
if dest.exists() && dest.is_dir() {
let _ = std::fs::remove_dir_all(dest);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use std::process::Command as StdCommand;
fn init_source_repo(path: &Path) {
fs::create_dir_all(path).unwrap();
fs::write(path.join("welcome.md"), "# Welcome\n").unwrap();
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.email", "tolaria@app.local"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", "Tolaria App"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(path)
.output()
.unwrap();
}
#[test]
fn test_clone_repo_clones_local_repository() {
let dir = tempfile::TempDir::new().unwrap();
let source = dir.path().join("source");
let dest = dir.path().join("dest");
init_source_repo(&source);
let result = clone_repo(source.to_str().unwrap(), dest.to_str().unwrap()).unwrap();
assert_eq!(result, format!("Cloned to {}", dest.to_string_lossy()));
assert!(dest.join(".git").exists());
assert!(dest.join("welcome.md").exists());
}
#[test]
fn test_clone_repo_nonempty_dest() {
let dir = tempfile::TempDir::new().unwrap();
fs::write(dir.path().join("existing.txt"), "data").unwrap();
let result = clone_repo("https://example.com/repo.git", dir.path().to_str().unwrap());
assert!(result.unwrap_err().contains("not empty"));
}
#[test]
fn test_clone_repo_empty_dest_allowed() {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("empty-dir");
fs::create_dir(&dest).unwrap();
let result = clone_repo(
"https://example.com/nonexistent/repo.git",
dest.to_str().unwrap(),
);
assert!(result.unwrap_err().contains("git clone failed"));
}
}

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