Compare commits

...

76 Commits

Author SHA1 Message Date
Test
b9d94abae4 style: rustfmt vault_config.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:58:39 +01:00
Test
18b2aaedf6 fix: add missing visible field to buildNewEntry in useNoteActions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:57:00 +01:00
Test
1706300494 test: add Playwright smoke test for visible type property
Also updates vite.config.ts vault API parser to include all VaultEntry
fields (visible, icon, color, order, etc.) so the dev server returns
complete entries for sidebar filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:52:40 +01:00
Test
628ab76f09 feat: migrate hidden_sections from ui.config to visible property on Type notes
On startup, reads hidden_sections from config/ui.config.md, creates or
updates Type notes with visible: false, then re-saves config without
hidden_sections. Idempotent and safe to run multiple times.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:44:30 +01:00
Test
d3896ddf01 refactor: remove hidden_sections from VaultConfig and delete useSectionVisibility
Sidebar section visibility is now controlled entirely by the `visible`
property on Type notes. Removes all hidden_sections references from
Rust struct, TypeScript interface, config migration, mock data, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:41:00 +01:00
Test
7289a60db3 feat: add handleToggleTypeVisibility to useEntryActions
Toggle visible property on Type note frontmatter: sets visible:false
to hide, deletes visible property to show (defaults to visible).
Wire up in App.tsx via onToggleTypeVisibility prop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:35:18 +01:00
Test
0cff626e48 feat: filter sidebar sections by Type entry visible property
Replace useSectionVisibility hook with direct filtering on
typeEntryMap[type]?.visible !== false. Add onToggleTypeVisibility
callback prop. Tests updated to verify visible:false hides sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:33:47 +01:00
Test
5a4c986fe3 feat: add visible field to VaultEntry for Type note sidebar visibility
Parse `visible` boolean from Type note frontmatter (defaults to None/true
when absent). Add to SKIP_KEYS to exclude from relationships and properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:30:18 +01:00
Test
540b1400e2 🐛 Auto-untrack .laputa-cache.json and .laputa/settings.json from git
These are machine-local files that should never be version controlled:
- .laputa-cache.json: contains absolute paths, changes on every machine
- .laputa/settings.json: per-machine UI settings

Fix: ensure_cache_excluded() now:
1. Adds both files to .git/info/exclude (git-level ignore, no .gitignore needed)
2. Runs `git rm --cached --ignore-unmatch` on vault open to un-track them
   if they were committed in older vaults

This is idempotent and self-healing — existing vaults fix themselves
automatically on next app launch without any manual steps.
2026-03-06 21:10:37 +01:00
Test
826cda852a Pulse: lazy pagination with IntersectionObserver infinite scroll
- Page size reduced from 30 → 20 commits per fetch
- Backend: add `skip` param to get_vault_pulse (git log --skip)
  → true pagination instead of re-fetching everything
- Frontend: IntersectionObserver sentinel at bottom of feed
  → auto-loads next page when user scrolls near end
- Append-only updates (no full re-render on load more)
- Add IntersectionObserver mock to test setup
2026-03-06 21:04:24 +01:00
Test
19583ea1f5 💅 Pulse: add right border, collapse commit files by default
- Add border-r border-[var(--sidebar-border)] to PulseView container
- CommitCard files default to collapsed (expanded: false) for cleaner initial state
- Update tests to reflect new collapsed-by-default behavior
2026-03-06 21:00:35 +01:00
Test
63eb4ff980 fix: address clippy lints in title_case_folder
Use char array pattern and function reference instead of closures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:11:57 +01:00
Test
bbb29857b8 test: add regression tests for hyphenated folder sidebar duplicates
- Sidebar unit test: entries with isA 'Monday Ideas' produce exactly
  one section header (not two)
- Playwright smoke test: verify no duplicate section labels in sidebar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:09:29 +01:00
Test
900ce7f66f fix: normalize hyphenated folder names in infer_type_from_folder()
Split on hyphens and underscores, capitalize each word, join with
spaces. E.g. monday-ideas → Monday Ideas, key-result → Key Result.
This prevents duplicate sidebar sections when folder name differs
from the Type file title.

Bump CACHE_VERSION to 5 to force rescan on existing caches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:59:51 +01:00
Test
eb55c5ec02 refactor: apply rustfmt formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:43:43 +01:00
Test
6f6e7d7cfe fix: vault cache misses files in new directories, breaking theme restore
Root cause: git_uncommitted_files() used `git status --porcelain` which
reports new untracked directories as `?? theme/` instead of listing
individual files inside. The .md filter skipped directory entries, so
files in newly-created directories (theme/default.md, etc.) were
invisible to the cache system.

Fix: additionally use `git ls-files --others --exclude-standard` which
lists individual untracked files, resolving directories to their
contents.

Also:
- Add type/theme.md definition (icon: palette) to restore and getting-started vault
- Seed theme/*.md vault notes in getting-started vault
- Fix mock create_vault_theme to add entries for dev mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:38:54 +01:00
Test
edcb306c7f fix: exclude .laputa-cache.json and settings.json from vault git tracking
These files are machine-specific and should never be committed or cause
conflicts when syncing vaults across devices.

Changes:
- init_repo() now writes .gitignore with .laputa-cache.json and
  .laputa/settings.json excluded before the first commit
- .DS_Store and common editor artifacts also excluded
- openConflictFileRef falls back to openLocalFile() for non-note files
  so 'Open in editor' works for .json conflict files (opens in system
  default app, e.g. TextEdit/VS Code)
- Removed stale git.rs (replaced by git/mod.rs from refactor)
- 2 new Rust tests for .gitignore creation behavior
2026-03-06 15:22:48 +01:00
Test
97be1d1ca3 fix: write .gitignore on vault init to exclude .DS_Store
macOS creates .DS_Store files in every folder which were being tracked
by git, causing constant conflicts when vaults are synced across machines.

init_repo() now writes a .gitignore before the first commit with:
- .DS_Store, .AppleDouble, .LSOverride
- ._ thumbnail files
- Common editor artifacts (.vscode/, .idea/, *.swp)

The file is only written if one doesn't already exist, so user-customized
.gitignore files are respected.

Two new Rust tests verify the behavior.
2026-03-06 15:19:25 +01:00
Test
ea29a81d79 refactor: split Rust backend into sub-modules — git, theme, github, frontmatter, commands
- git.rs (1907 lines) → 7 files: mod, history, status, commit, remote, conflict, pulse
- theme.rs (1075 lines) → 4 files: mod, defaults, seed, create
- github.rs (886 lines) → 4 files: mod, api, auth, clone
- frontmatter.rs (827 lines) → 3 files: mod, yaml, ops
- lib.rs (772 lines) → lib.rs (160) + commands.rs (650)

484 tests pass, clippy clean, rustfmt clean, coverage 85.42%
2026-03-06 13:16:32 +01:00
Test
5b1fda2279 chore: exclude tools/, scripts/ from CodeScene analysis
tools/qmd/ contains vendored qmd source code (not our code) that was
dragging the overall code health score down to 6.53.
scripts/ contains one-shot utility scripts, not production code.

Excluding both from CodeScene so the health metric reflects only
the actual app codebase (src/, src-tauri/src/).
2026-03-06 11:32:22 +01:00
Test
1cd596061a fix: latest.json must point to .tar.gz not .dmg for Tauri in-app updater
The Tauri updater plugin requires the .app.tar.gz artifact as the update
URL — not the .dmg installer. The DMG is for fresh installs only.
This was causing 'Install Update' to silently fail on all macOS builds
since the auto-updater infrastructure was set up.

Change ARM_DMG → ARM_TARBALL, pointing to the .app.tar.gz artifact.
2026-03-06 10:54:04 +01:00
Test
efb233b18f feat: add Playwright smoke test infrastructure for task-scoped QA
Adds headless Chromium smoke tests that run before push, catching
UI/UX bugs before Brian QA. Includes shared helpers for command
palette and keyboard shortcut testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 09:39:20 +01:00
Test
fd34df8db0 ci: import Apple certificate before Tauri build so beforeBuildCommand can codesign
bundle-qmd.sh signs qmd/qmd and vec0.dylib with Developer ID + hardened
runtime, but the certificate wasn't in the keychain yet when it ran
(Tauri imports the cert internally, AFTER beforeBuildCommand completes).

Fix: add explicit 'Import Apple Developer certificate' step before the
Tauri build step, using security create-keychain + security import.
This makes the cert available to codesign during beforeBuildCommand.
2026-03-06 09:10:46 +01:00
Test
244deeb727 fix: sign qmd binaries with Developer ID + hardened runtime for notarization
Apple notarization rejected qmd/qmd and qmd/vec0.dylib due to:
- Not signed with valid Developer ID certificate
- Hardened runtime not enabled

Changes:
- bundle-qmd.sh: use APPLE_SIGNING_IDENTITY + --options runtime --timestamp in CI
  (falls back to ad-hoc signing in dev when no identity is set)
- useThemeManager.test.ts: add ensure_vault_themes to mock (added by theme
  editor feature, missing from stale theme ID test mock)
2026-03-06 08:43:54 +01:00
Test
70f94d3a51 docs: add mandatory Playwright Phase 1 QA step before laputa-task-done
Claude Code must now run Playwright smoke tests against the dev server
before firing the done signal. This catches UI/UX bugs (missing Cmd+K
commands, broken shortcuts, layout issues) before Brian's native QA.

- Phase 1 (Playwright, headless) = Claude Code's responsibility
- Phase 2 (native Tauri, keyboard-only) = Brian's responsibility

Phase 1 covers: command palette entries, keyboard shortcuts, Tab
navigation, UI state changes. Phase 2 covers: file system, git, native
Tauri behaviors that can't run in the browser.
2026-03-06 08:36:01 +01:00
Test
bb20ef17f6 fix: bundle qmd source in tools/qmd/ so CI can compile it
bundle-qmd.sh was trying to install qmd via 'bun install -g qmd' which
installs a different public npm package, not Luca's qmd tool. CI runners
(runner user) don't have the local qmd installation.

Fix:
- Copy qmd source (src/, package.json, tsconfig.json, bun.lock) to tools/qmd/
- Update bundle-qmd.sh to prefer tools/qmd/ as QMD_SRC
- Run 'bun install --frozen-lockfile' in QMD_SRC if node_modules missing
- Update sqlite-vec lookup to find packages from node_modules after bun install
- Compilation uses 'cd $QMD_SRC && bun build --compile src/qmd.ts'
- Add tools/ to eslint globalIgnores (qmd source has its own lint standards)
- Local dev machines still work (tools/qmd/ takes priority over global install)
2026-03-06 08:22:20 +01:00
Test
15a1ba6829 ci: add bun setup step to release workflow (required by bundle-qmd.sh)
bundle-qmd.sh uses bun to compile the qmd binary. Release runners
don't have bun pre-installed on self-hosted macOS runners.
Add oven-sh/setup-bun@v2 before the Rust setup step.
2026-03-06 07:23:37 +01:00
Test
e2c6669fd6 fix: align ws-bridge.js imports with simplified vault.js API
After ai-agent-full-shell simplified vault.js to read-only, ws-bridge.js
still imported removed functions (createNote, appendToNote, editNoteFrontmatter,
deleteNote, linkNotes, listNotes, readNote) — breaking CI bundle step.

Fix:
- Import only getNote, searchNotes, vaultContext from vault.js
- Update open_note/read_note handlers to use getNote
- Remove write tool handlers — agent uses native bash/write tools
- Remove orphaned buildFrontmatter helper
2026-03-06 07:17:10 +01:00
Luca Rossi
d4098d3308 refactor: split InspectorPanels.tsx into focused modules — reduce 538-line file to focused per-panel components (#189)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:11:00 +01:00
Luca Rossi
1c3d677851 test: add configMigration tests — cover all migration branches (#188)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:46:10 +01:00
Test
3da0b0e652 fix: make qmd/search work on fresh installs — auto-install, fix permissions, sign binaries
On fresh MacBook installs, the bundled qmd binary fails to run due to:
missing execute permissions, macOS quarantine attributes, and no fallback
when qmd is completely absent. This fix addresses all three issues:

- Runtime: ensure +x permissions and remove quarantine on bundled qmd
- Runtime: auto-install qmd via bun when binary not found anywhere
- Build: ad-hoc code-sign qmd and .dylib files in bundle-qmd.sh
- Build: create placeholder resource dirs so fresh clones build cleanly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:13:03 +01:00
Test
bc2f97d1d4 style: rustfmt search.rs 2026-03-05 21:20:51 +01:00
Test
2fb6a30dff feat: bundle qmd binary with app — search works on fresh installs
Replace the fragile auto-install-via-bun approach with a bundled qmd binary.
The build script (scripts/bundle-qmd.sh) compiles qmd into a standalone
binary using `bun build --compile`, then packages it with sqlite-vec native
extensions and a node-llama-cpp stub for keyword-only search.

Key changes:
- find_qmd_binary() now returns QmdBinary with path + work_dir, checks
  bundled resource first (app bundle and dev mode), then system paths
- All Command::new(qmd_path) calls updated to use QmdBinary::command()
  which sets the correct working directory for node_modules resolution
- Removed auto_install_qmd() and find_bun() — no longer needed
- Tauri config bundles resources/qmd/** into the app

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:20:51 +01:00
Test
0206fb3720 fix: rank command palette results by match score, not section order
When searching in Cmd+K, groups are now ordered by their highest-scoring
match instead of the fixed section order. Empty query preserves the
default section ordering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:00:31 +01:00
Test
51d1b28460 fix: move Go Back/Forward to Go menu, add toggles to Note menu
Move Go Back + Go Forward from View menu to Go menu where they
logically belong. Move Toggle Raw Editor, Toggle AI Chat, and
Toggle Backlinks into the Note menu for better discoverability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:59:23 +01:00
Test
0183062467 fix: always show Resolve Conflicts in Cmd+K — show toast when no conflicts 2026-03-05 18:18:52 +01:00
Test
1c244a85eb refactor: extract hooks and components from NoteListInner for code health
Split NoteListInner (190 lines, 14 props) into focused units:
- useNoteListSort: sort state, migration, type frontmatter persistence
- useNoteListSearch: search/query state and toggle
- useMultiSelectKeyboard: keyboard shortcuts for bulk actions
- useModifiedFilesState: modified file tracking and status resolution
- NoteListHeader: 52px header bar with sort, search, and create

Also extracted pure helpers (handleEscapeKey, handleSelectAllKey,
handleBulkActionKey, resolveEmptyText, deriveEffectiveSort, etc.)
to reduce cyclomatic complexity in remaining functions.

NoteListInner body: 190 → 48 lines. CodeScene health: 8.54 → 9.36.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:50:42 +01:00
Test
900755055b style: rustfmt git.rs pulse functions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:29:31 +01:00
Test
3af9a09d29 feat: add Pulse — vault activity feed showing git commit history
Adds a new Pulse sidebar section that shows chronological git commit
history for the vault. Commits are grouped by day with message, time,
short hash (clickable GitHub link when remote configured), file list
with add/modify/delete status icons, and summary badges. Clicking a
file opens the note in the editor. Disabled with tooltip for non-git
vaults. Accessible via sidebar click or "Go to Pulse" command palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:27:29 +01:00
Test
348b2654eb style: apply rustfmt to vault_config.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:30:37 +01:00
Test
013cf0ffe1 feat: move vault UI config from localStorage to vault files
Add VaultConfig infrastructure (store, hook, migration) that persists
zoom, view mode, section visibility, tag/status colors, and property
display modes to config/ui.config.md in the vault instead of localStorage.

- New vaultConfigStore module with subscribe/notify pattern
- useVaultConfig hook loads config via Tauri, binds store, runs migration
- One-time silent migration from localStorage on first load
- Config type excluded from note search and unified search
- All hooks/utils updated to read/write through vault config store
- Tests updated to use vault config store instead of localStorage mocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:26:21 +01:00
Test
418ea8a7a8 feat: add vault_config module and view field to VaultEntry
Rust backend for vault-specific configuration stored as
config/ui.config.md — a regular vault note with YAML frontmatter.
Adds `view` field to VaultEntry for per-type view mode preferences.
Registers get_vault_config and save_vault_config Tauri commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:05:57 +01:00
Test
1e3c296787 fix: pass undefined to useRef for strict TypeScript compat
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:42:28 +01:00
Test
32b4a90ae5 feat: expose all theme.json properties in theme editor
Add ThemePropertyEditor component that surfaces all customizable
properties from theme.json — typography, headings, lists, code blocks,
blockquote, table, and horizontal rule — organized into collapsible
sections with appropriate input types (number, color, select, text).

- themeSchema.ts: derives flat property list from theme.json with
  auto-detected input types, units, and select options
- ThemePropertyEditor.tsx: sectioned editor with collapsible sections,
  keyboard-accessible toggles, debounced live updates
- ThemeManager: add updateThemeProperty() and activeThemeContent
- SettingsPanel: show property editor below theme list when active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:42:28 +01:00
Test
25260c7d58 style: apply rustfmt to menu.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:34:03 +01:00
Test
ff3e7af65a fix: move view-toggle-backlinks to simple event map for type safety
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:34:03 +01:00
Test
75878c8b64 feat: reorganize menu bar with Go, Note, and Vault menus
Add missing command palette commands to menu bar and reorganize into
logical groups: File, Edit, View, Go, Note, Vault, Window. New menus
expose navigation filters, note actions, vault management, themes, and
git operations. Context-sensitive items (archive, trash, diff, raw
editor, commit, conflicts) are greyed out when not applicable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:34:03 +01:00
Test
8f0c6e04fe test: add color detection tests to propertyTypes
Verify hex colors are detected as 'color' display mode, named colors
with color-related keys are detected, and invalid colors are rejected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:57:17 +01:00
Test
adf45a51b5 feat: add color swatch + picker for property values
Add inline color swatch preview next to hex/CSS color property values in
the Properties panel. Clicking the swatch opens the native OS color
picker. Add 'color' property display mode with auto-detection for hex
values and color-related key names (background, primary, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:35:10 +01:00
Test
96bc4e935a fix: enable line wrapping in raw editor
Long lines now wrap to the next visual line instead of scrolling
horizontally off-screen, matching expected behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:57:16 +01:00
Test
eab457b388 fix: make Restore Default Themes command always enabled in Cmd+K palette
The enabled guard `!!onRestoreDefaultThemes` could evaluate to false at
runtime, hiding the command from the palette. Since the handler is always
defined via useCallback, the guard is unnecessary. Changed to enabled: true,
matching the pattern used by other always-available commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:32:32 +01:00
Test
89da970455 feat: enable full shell access for AI agent + simplify MCP tools
Remove --tools "" restriction so the agent has native bash/read/write/edit
access. Set vault path as working directory for the subprocess.

Simplify MCP to 4 Laputa-specific tools (search_notes, get_vault_context,
get_note, open_note) — everything else is handled by native tools.

Add file operation detection from Write/Edit tool calls to auto-open
created notes and refresh modified notes in the UI. Enhanced tool call
labels show bash commands, file paths, and note names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:12:12 +01:00
Test
6a57e83c99 style: rustfmt collect_wikilink_inner signature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:53:15 +01:00
Test
d41e4ea34a fix: strip wikilink brackets from note list preview snippets
[[target]] now shows as "target" and [[target|alias]] shows as "alias"
in note list previews, instead of raw bracket syntax.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:30:43 +01:00
Test
c371e26dcb feat: Add 'Restore Default Themes' command for vaults missing theme structure
- New restore_default_themes() Rust fn: seeds both _themes/ and theme/ dirs
- Per-file idempotent: never overwrites existing files with content
- Fixed ensure_vault_themes() to include minimal.md (was missing)
- New 'Restore Default Themes' command in Cmd+K Appearance group
- 3 new Rust tests + 4 new frontend tests
- 1676 frontend tests passed
2026-03-05 11:18:53 +01:00
Test
c78018b92a fix: rustfmt formatting in git.rs 2026-03-05 10:50:43 +01:00
Test
5e84ebc28a fix: stub WebSocket in test setup to prevent Node 22 + undici crash 2026-03-05 10:49:52 +01:00
Test
5775cb0c96 fix: detect and resolve rebase conflicts in sync conflict resolution
The previous conflict detection only worked for merge-based pulls
(--no-rebase) but failed to detect pre-existing conflicts from
interrupted rebases or prior sessions. This fixes three root causes:

1. Rust: add is_rebase_in_progress/is_merge_in_progress/get_conflict_mode
   helpers, and dispatch git_commit_conflict_resolution between
   `git commit` (merge) and `git rebase --continue` (rebase)
2. Frontend: add startup conflict check via get_conflict_files before
   pulling, so pre-existing conflicts are detected on app launch
3. App.tsx: handleOpenConflictResolver now fetches conflicts directly
   when the cached list is empty, preventing the silent early-return

Also exposes get_conflict_files and get_conflict_mode as Tauri commands
so the frontend can independently check conflict state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:34:25 +01:00
Test
7ced48d001 fix: rustfmt formatting in trash regression tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:02:20 +01:00
Test
533c9da4d0 fix: trashed notes reappear after restart due to frontmatter key casing mismatch
Frontend wrote `trashed` (lowercase) but Rust parser expected `Trashed`
(title-case via serde rename). On restart, the lowercase key didn't match,
defaulted to false, and trashed notes reappeared.

- Frontend: use title-case keys (Trashed, Trashed at) matching vault convention
- Rust: add serde aliases for lowercase keys (backward compat with already-written files)
- Add regression tests for both title-case and lowercase parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:00:56 +01:00
Luca Rossi
e1afaaa5b6 refactor: extract usePropertyPanelState hook into dedicated file (#187)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 07:11:33 +01:00
Test
914bcfdafd fix: stub fetch in test setup to prevent jsdom@28 + Node 22 undici crash
jsdom@28's JSDOMDispatcher passes an onError handler incompatible with
Node 22's bundled undici, causing InvalidArgumentError (UND_ERR_INVALID_ARG)
on CI. Stubbing globalThis.fetch prevents the dispatcher from being invoked.
The previous uncaughtException handler was insufficient — it caught the wrong
error code and didn't handle unhandled rejections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:30:52 +01:00
Test
cfb047cb22 feat: wikilink pills in message bubbles, noteList context injection
- Render [[wikilink]] reference pills inside sent message bubbles
  with type-colored badges; clicking a pill opens the note
- Add noteList (filtered note list titles, max 100) and noteListFilter
  to the structured context snapshot sent to the AI
- Thread noteList/noteListFilter from App → Editor → EditorRightPanel → AiPanel
- Store references in AiAgentMessage for display in chat history
- Add tests for reference pill rendering and noteList context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:53:46 +01:00
Test
55ff9e6f5d refactor: remove dead useMcpRegistration hook, add design file
useMcpRegistration is fully replaced by useMcpStatus which combines
detection + registration. Added design placeholder for MCP status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:36:36 +01:00
Test
d3ea632673 feat: detect MCP server status and show warning in status bar
Add check_mcp_status Tauri command that detects whether the MCP server
is installed, Claude CLI is missing, or config needs setup. The status
bar shows a warning badge (MCP ⚠) when not installed, clickable to
trigger install. Also available via command palette "Install MCP Server".

Replaces useMcpRegistration with useMcpStatus which combines detection
and registration in a single hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:11:58 +01:00
Test
6d3d752fd5 fix: pass initial value to useRef for strict TS build
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:21:40 +01:00
Test
26181b57b6 test: add tests for WikilinkChatInput and buildContextSnapshot
- WikilinkChatInput: 18 tests covering menu trigger, filtering, pill creation,
  dedup, removal, keyboard nav, Enter select, send with refs, disabled state
- buildContextSnapshot: 10 tests for structured context JSON output
  including activeNote, openTabs, vault summary, references, frontmatter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:19:23 +01:00
Test
7efcaa11c4 feat: add wikilink autocomplete, animated border, structured context wiring
- WikilinkChatInput: [[ trigger, debounced dropdown, colored type pills, keyboard nav
- AiPanel: uses buildContextSnapshot, WikilinkChatInput, animated blue border
- EditorRightPanel/Editor: thread openTabs to AI panel
- CSS: ai-border-pulse + typing-bounce animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:15:06 +01:00
Test
197aad0e97 feat: add reasoning streaming, markdown response, structured context snapshot
- Rust: add ThinkingDelta event to ClaudeStreamEvent for reasoning chunks
- ai-agent.ts: forward ThinkingDelta events via onThinking callback
- useAiAgent: stream reasoning live, accumulate response internally,
  reveal as complete block on done
- AiMessage: auto-collapse reasoning when done, use MarkdownContent
  for response rendering, update tests for new behavior
- ai-context: add buildContextSnapshot() for structured JSON context
  with activeNote, openTabs, noteListFilter, vault summary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:09:02 +01:00
Test
db47ffe454 fix: use generic Error type in setup.ts to avoid NodeJS namespace 2026-03-04 11:20:02 +01:00
Test
008f067bf7 fix: restore drag-to-reorder for sidebar sections
Re-add useSortable listeners that were removed in the realignment
refactor. The entire section header row is now the drag target (no
visible handle icon needed). PointerSensor's distance:5 constraint
ensures clicks for collapse/expand don't conflict with drag.

Also suppress pre-existing undici WebSocket ERR_INVALID_ARG_TYPE in
test setup (jsdom Event ≠ Node Event incompatibility).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 11:07:56 +01:00
Test
55a2509658 fix: MCP UI tools (highlight, open_note) now work in real-time
Root causes:
- index.js tried to start its own UI bridge server on port 9711, but
  ws-bridge.js (spawned by Tauri) already owns it → broadcastUiAction
  was a no-op. Fixed by connecting index.js as a WebSocket CLIENT that
  sends messages through the existing bridge.
- ws-bridge.js UI bridge had no relay — client messages weren't forwarded.
  Added relay so messages from the MCP server reach the React frontend.
- useAiActivity hook existed but was never imported in App.tsx.
- useAiActivity only handled highlight, not open_note/open_tab/set_filter.
- No vault_changed events after write operations.
- set_filter payload used `type` key which overwrote `type: 'ui_action'`.

Changes:
- mcp-server/index.js: connect as WS client instead of starting server;
  broadcast vault_changed after all write operations
- mcp-server/ws-bridge.js: add message relay in UI bridge; broadcast
  vault_changed after write operations; fix set_filter payload key
- useAiActivity: handle all UI actions (highlight, open_note, open_tab,
  set_filter, vault_changed); accept callbacks; auto-reconnect on close
- App.tsx: wire useAiActivity into vault/notes/selection actions; apply
  ai-highlight CSS class to editor and note list panels
- App.css: add ai-highlight-glow keyframe animation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:05:43 +01:00
Test
790f2ea85c style: apply rustfmt to theme.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:35:49 +01:00
Test
ed5e6d6820 fix: auto-provision theme files on vault open for any vault
seed_vault_themes now writes individual missing/empty files instead of
skipping when the theme/ directory already exists. A new
ensure_vault_themes Tauri command is called by useThemeManager on every
vault open so vaults without a theme/ folder get default.md and dark.md
seeded automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:34:51 +01:00
Test
feb97caa87 fix: show 'Installing search...' when qmd missing instead of 'Indexing...'
On fresh installs without qmd, show the accurate "Installing search..."
phase instead of briefly flashing "Indexing..." before switching to
unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:45:24 +01:00
Test
5bcd344d5f fix: replace 'Index error' with graceful handling on fresh installs
Separate 'unavailable' (qmd not installed) from 'error' (indexing failed)
phases. Unavailable state is hidden from the status bar instead of showing
a persistent orange error. Actual errors show "Index failed — retry" with
click-to-retry. Both phases auto-dismiss after a timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:36:10 +01:00
176 changed files with 29186 additions and 47739 deletions

View File

@@ -1 +1 @@
45402
33549

9
.codescenerc Normal file
View File

@@ -0,0 +1,9 @@
{
"exclude": [
"tools/",
"scripts/",
"src-tauri/gen/",
"coverage/",
"dist/"
]
}

View File

@@ -55,6 +55,11 @@ jobs:
node-version: '22'
cache: 'pnpm'
- name: Setup Bun (required for bundle-qmd.sh)
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
@@ -80,6 +85,24 @@ jobs:
jq --arg v "$VERSION" '.version = $v' src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json
sed -i '' "s/^version = \".*\"/version = \"$VERSION\"/" src-tauri/Cargo.toml
- name: Import Apple Developer certificate into keychain
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
# 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)"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Build Tauri app (with signing + notarization)
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -153,7 +176,7 @@ jobs:
REPO="refactoringhq/laputa-app"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
cat > latest.json << EOF
{
@@ -163,7 +186,7 @@ jobs:
"platforms": {
"darwin-aarch64": {
"signature": "${ARM_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
}
}
}

View File

@@ -47,26 +47,26 @@ fi
# ── 0. TypeScript + Vite build ──────────────────────────────────────────
echo ""
echo "📦 [0/4] TypeScript + Vite build..."
echo "📦 [0/5] TypeScript + Vite build..."
pnpm exec tsc --noEmit
pnpm build
echo " ✅ Build OK"
# ── 1. Frontend coverage (≥70%) — includes all unit tests ───────────────
echo ""
echo "📊 [1/4] Frontend tests + coverage (≥70%)..."
echo "📊 [1/5] Frontend tests + coverage (≥70%)..."
pnpm test:coverage --silent
echo " ✅ Frontend coverage OK"
# ── 2. Rust lint (clippy + fmt) — fast, run before coverage ─────────────
echo ""
if [ "$RUST_CHANGED" = true ]; then
echo "🔧 [2/4] Clippy + rustfmt..."
echo "🔧 [2/5] Clippy + rustfmt..."
cargo clippy --manifest-path=src-tauri/Cargo.toml -- -D warnings
cargo fmt --manifest-path=src-tauri/Cargo.toml -- --check
echo " ✅ Rust lint OK"
else
echo "⏭️ [2/4] Rust lint — skipped (no src-tauri/ changes)"
echo "⏭️ [2/5] Rust lint — skipped (no src-tauri/ changes)"
fi
# ── 3. Rust coverage (≥85% lines) ──────────────────────────────────────
@@ -75,9 +75,9 @@ if [ "$RUST_CHANGED" = true ]; then
LLVM_COV_FLAGS="--no-clean"
if [ "${LAPUTA_FULL_COVERAGE:-0}" = "1" ]; then
LLVM_COV_FLAGS=""
echo "🦀 [3/4] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
echo "🦀 [3/5] Rust coverage — FULL (LAPUTA_FULL_COVERAGE=1)..."
else
echo "🦀 [3/4] Rust coverage (≥85%, incremental)..."
echo "🦀 [3/5] Rust coverage (≥85%, incremental)..."
fi
# Unset GIT_DIR so git tests create isolated repos without inheriting hook context
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
@@ -90,12 +90,23 @@ if [ "$RUST_CHANGED" = true ]; then
-- --test-threads=1
echo " ✅ Rust coverage OK"
else
echo "⏭️ [3/4] Rust coverage — skipped (no src-tauri/ changes)"
echo "⏭️ [3/5] Rust coverage — skipped (no src-tauri/ changes)"
fi
# ── 4. CodeScene code health gate (≥9.2) ────────────────────────────────
# ── 4. Playwright smoke tests (if any exist) ──────────────────────────
echo ""
echo "🏥 [4/4] CodeScene code health gate (≥9.2)..."
SMOKE_FILES=$(find tests/smoke -name '*.spec.ts' 2>/dev/null | head -1)
if [ -n "$SMOKE_FILES" ]; then
echo "🎭 [4/5] Playwright smoke tests..."
pnpm playwright:smoke
echo " ✅ Smoke tests OK"
else
echo "⏭️ [4/5] Playwright smoke tests — skipped (no tests/smoke/*.spec.ts)"
fi
# ── 5. CodeScene code health gate (≥9.2) ────────────────────────────────
echo ""
echo "🏥 [5/5] CodeScene code health gate (≥9.2)..."
if [ -z "$CODESCENE_PAT" ] || [ -z "$CODESCENE_PROJECT_ID" ]; then
echo " ⚠️ CODESCENE_PAT or CODESCENE_PROJECT_ID not set — skipping"
else

141
CLAUDE.md
View File

@@ -15,11 +15,40 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
**CI is a safety net, not a discovery tool.** If CI catches something you didn't catch locally, that's a process failure. All these tools are available locally — use them while you code, not just at the end.
## ⛔ BEFORE FIRING laputa-task-done — QA on real vault
## ⛔ BEFORE FIRING laputa-task-done — Two-phase QA (mandatory)
> **⚠️ TAURI APP ONLY — never test in browser (`pnpm dev` / localhost)**
> The browser dev server has a fake HTTP server (`/api/*` routes, mock handlers) that doesn't exist in the real Tauri app.
> If it works in the browser but crashes in Tauri, it's broken. Always test in `pnpm tauri dev`.
### Phase 1: Playwright browser QA (headless, you do this yourself)
Test every acceptance criterion using Playwright against the dev server **before** marking done. This catches 80% of bugs before Brian sees them.
```bash
# 1. Start the dev server (use your worktree port)
pnpm dev --port <N> &
DEV_PID=$!
sleep 3 # wait for vite to be ready
# 2. Run Playwright smoke test for this task
BASE_URL="http://localhost:<N>" npx playwright test tests/smoke/<slug>.spec.ts
# 3. Or run all smoke tests
BASE_URL="http://localhost:<N>" pnpm playwright:smoke
kill $DEV_PID
```
**What to test in Playwright:**
- Every command palette entry from the spec → open `Cmd+K`, type the command name, verify it appears and executes
- Every keyboard shortcut → send keydown events, verify UI state changes
- Every UI element described in the spec → verify it renders, is focusable, responds to Tab
- Edge cases: empty state, long text, rapid keypresses
**Playwright is non-negotiable even if tests pass.** Unit tests verify code; Playwright verifies the user experience in the real browser. Both are required.
> **⚠️ Browser dev server limits**: the dev server uses mock Tauri handlers (`src/mock-tauri.ts`) — file system operations, git commands, and native dialogs are mocked. Test those via `pnpm tauri dev` in Phase 2 if the task touches them.
### Phase 2: Native Tauri QA (Brian does this after you push)
Brian installs the release build and runs keyboard-only QA on the native app. You don't do Phase 2 — but Phase 1 must pass before you fire the done signal, or Brian's QA will fail and the task goes back to To Rework.
1. Acquire lockfile: `echo $$ > /tmp/laputa-qa.lock && trap "rm -f /tmp/laputa-qa.lock" EXIT`
2. Kill other instances: `pkill -x laputa 2>/dev/null || true; sleep 1`
@@ -29,6 +58,19 @@ pre_commit_code_health_safeguard # CodeScene ≥9.2 — if it fails, fix stru
6. If task touches file save: verify `git -C ~/Laputa diff` shows changes
7. If QA fails → fix and re-run. Do NOT fire the signal until it passes.
**⚠️ QA ≠ tests. QA means using the app as a user.**
- "Tests pass" is NOT QA. Tests verify code, QA verifies the user experience.
- The QA comment must describe what you did as a user: "Opened app → Cmd+K → typed 'Trash' → pressed Enter → note disappeared from list → restarted app → note still not visible"
- Every QA comment must include: the exact keyboard/command palette steps used, what was visible before and after, and any edge case tested.
- If you cannot test a feature using keyboard only (osascript shortcuts + command palette), the feature is not keyboard-first → QA fails.
**⚠️ Test in a clean environment when the feature depends on state.**
If a feature involves indexing, fresh installs, first-time setup, or anything that only runs once:
- **Do not test in the existing dev vault** — it already has the state you're trying to test.
- **Create a new empty vault** for the test: Cmd+K → "New Vault" (or equivalent), pick a temp folder like `/tmp/test-vault-<slug>`, then test the full first-time flow from scratch.
- This applies to: search indexing, vault init, getting-started setup, any "on first open" logic.
- If you can't reproduce the fresh-install scenario locally, the feature is untestable → do not fire done.
Fire done signal only after QA passes:
```bash
rm -f /tmp/laputa-qa.lock
@@ -74,13 +116,47 @@ Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with
- **Never develop on `main`** — always on `task/<slug>` branch
- **Commit every 2030 min** — atomic commits, one concern per commit (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`)
- **Update docs/** when changing architecture, abstractions, or significant design
- **Test as you go** — write tests alongside code, not after
## Testing
## TDD — Red/Green/Refactor (mandatory)
**Always use test-driven development.** No production code without a failing test first.
The loop:
1. **Red** — write a failing test that describes the behavior you want. Run it, confirm it fails for the right reason.
2. **Green** — write the minimum code to make the test pass. No more, no less.
3. **Refactor** — clean up the code (extract, rename, simplify) while keeping tests green.
4. **Commit** — one red/green/refactor cycle = one atomic commit.
5. Repeat.
**Why this matters:**
- Forces you to think about behavior before implementation
- Produces only code that's actually needed (no speculative abstractions)
- Tests written first are always behavioral and structure-insensitive by construction
- Tiny cycles = fast feedback, smaller diffs, easier to review
**For bug fixes:**
1. Write a failing test that reproduces the bug (this is the regression test)
2. Fix the bug until the test passes
3. Commit both together: `fix: [bug] — regression test added`
**For Rust:**
```bash
cargo watch -x test # run tests on every save
```
**For frontend:**
```bash
pnpm test --watch # run tests on every save
```
**When to deviate:** Pure UI layout/styling work with no logic is the only exception. Everything else — hooks, utilities, Rust commands, state management — must be TDD.
## Testing (quality bar)
- Unit tests must cover real business logic, not "component renders"
- Every bug fixed manually → add a regression test
- Every new feature → new tests for new behavior paths
- Tests test **behavior** (what the code does), not **structure** (how it does it)
- Every bug fixed → regression test that would have caught it
- Every new feature → TDD from the start (see above)
- `pnpm test:coverage` and `cargo llvm-cov` must pass before committing
## Design File (every UI task)
@@ -103,6 +179,33 @@ node -e "const f=JSON.parse(require('fs').readFileSync('ui-design.pen','utf8'));
echo '{"children":[],"variables":{}}' > design/<slug>.pen
```
## Vault File Retrocompatibility (mandatory for every feature that adds vault files)
Laputa vaults are long-lived. New app versions must work on existing vaults that were created before a feature existed.
**Rule: never assume a vault file exists. Always auto-create if missing.**
Every feature that depends on a vault file or folder must:
1. **Auto-bootstrap on vault open** — check if the required file/folder exists; if not, create it with defaults. This must be silent and non-blocking.
2. **Be idempotent** — creating defaults must be safe to run multiple times (never overwrite user data).
3. **Expose a repair command** — add a `Cmd+K` command like "Restore Default Themes" or "Repair Vault Config" that explicitly re-creates missing files. Users can run this if something is broken.
**General "Repair Vault" command** — when adding a new vault file dependency, register it with the central repair system so that `Cmd+K → "Repair Vault"` fixes everything in one shot.
**Pattern:**
```
on vault open:
if file X does not exist → create X with defaults ← silent auto-repair
if file X exists but is malformed → log warning, use defaults (don't crash)
on "Repair Vault" command:
for each known vault file/folder:
if missing → create with defaults
if present → leave untouched (idempotent)
```
This principle applies to: themes, config files, type files, any `.laputa/` subfolder, or any file Laputa expects to find in a vault.
## macOS / Tauri Gotchas
- `Option+N` on macOS → special chars (`¡`, `™`), not `key:'N'`. Use `e.code` or `Cmd+N`.
@@ -118,6 +221,28 @@ bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
bash ~/.openclaw/skills/laputa-qa/scripts/click.sh 400 300 # logical coords
```
## Menu Bar Discoverability (mandatory for every new command)
The command palette is powerful but not discoverable — users must already know a command exists to find it. The macOS menu bar is where users discover what an app can do.
**Rule: every significant command palette entry must also appear in the menu bar.**
When adding a new command to the palette:
1. **Identify the right menu bar group** — File, Edit, View, Note, Vault, or create a new group if needed
2. **Add a menu item** with the same label as the palette command
3. **Show the keyboard shortcut** next to the menu item (if one exists)
4. **If no direct shortcut exists**, still add the menu item — it's discoverable and triggers the same action
The menu bar should be organized around what Laputa does:
- **File** — new note, open vault, switch vault, close
- **Edit** — undo, redo, find, note actions (rename, trash, duplicate)
- **View** — view modes, zoom, sidebar, panels
- **Note** — note-specific actions (move to trash, archive, properties)
- **Vault** — vault management (themes, config, repair, sync)
- **Window / Help** — standard macOS items
**This is a QA requirement:** before marking any task done, verify that every new command palette entry has a corresponding menu bar item.
## Keyboard-First Principle (mandatory for every new feature)
Every feature must be reachable via keyboard. This is both a UX requirement and a QA requirement — Brian tests the native app using keyboard only (osascript key events, no mouse).

View File

@@ -0,0 +1,3 @@
{
"theme": null
}

View File

@@ -0,0 +1,33 @@
{
"name": "Dark",
"description": "Dark variant with deep navy tones",
"colors": {
"background": "#0f0f1a",
"foreground": "#e0e0e0",
"card": "#16162a",
"popover": "#1e1e3a",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#2a2a4a",
"secondary-foreground": "#e0e0e0",
"muted": "#1e1e3a",
"muted-foreground": "#888888",
"accent": "#2a2a4a",
"accent-foreground": "#e0e0e0",
"destructive": "#f44336",
"border": "#2a2a4a",
"input": "#2a2a4a",
"ring": "#155DFF",
"sidebar-background": "#1a1a2e",
"sidebar-foreground": "#e0e0e0",
"sidebar-border": "#2a2a4a",
"sidebar-accent": "#2a2a4a"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}

View File

@@ -0,0 +1,33 @@
{
"name": "Default",
"description": "Light theme with warm, paper-like tones",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"destructive": "#E03E3E",
"border": "#E9E9E7",
"input": "#E9E9E7",
"ring": "#155DFF",
"sidebar-background": "#F7F6F3",
"sidebar-foreground": "#37352F",
"sidebar-border": "#E9E9E7",
"sidebar-accent": "#EBEBEA"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}

View File

@@ -0,0 +1,33 @@
{
"name": "Minimal",
"description": "High contrast, minimal chrome",
"colors": {
"background": "#FAFAFA",
"foreground": "#111111",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#000000",
"primary-foreground": "#FFFFFF",
"secondary": "#F0F0F0",
"secondary-foreground": "#111111",
"muted": "#F5F5F5",
"muted-foreground": "#666666",
"accent": "#F0F0F0",
"accent-foreground": "#111111",
"destructive": "#CC0000",
"border": "#E0E0E0",
"input": "#E0E0E0",
"ring": "#000000",
"sidebar-background": "#F5F5F5",
"sidebar-foreground": "#111111",
"sidebar-border": "#E0E0E0",
"sidebar-accent": "#E8E8E8"
},
"typography": {
"font-family": "'SF Mono', 'Menlo', monospace",
"font-size-base": "13px"
},
"spacing": {
"sidebar-width": "220px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,33 @@
{
"colors": {
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"background": "#FFFFFF",
"border": "#E9E9E7",
"card": "#FFFFFF",
"destructive": "#E03E3E",
"foreground": "#37352F",
"input": "#E9E9E7",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"ring": "#155DFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"sidebar-accent": "#EBEBEA",
"sidebar-background": "#F7F6F3",
"sidebar-border": "#E9E9E7",
"sidebar-foreground": "#37352F"
},
"description": "Light theme with warm, paper-like tones",
"name": "Untitled Theme",
"spacing": {
"sidebar-width": "250px"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
}
}

View File

@@ -0,0 +1,5 @@
---
type: config
zoom: 1.3
view_mode: all
---

View File

@@ -4,6 +4,8 @@ Is A: Note
Author: "Clayton Christensen"
Topics: ["[[topic-saas-business]]"]
URL: "https://example.com/innovators-dilemma"
trashed: true
trashed_at: 2026-03-04
---
# The Innovator's Dilemma
*Clayton Christensen*

View File

@@ -0,0 +1,5 @@
# Wikilinks QA Test
See [[ProjectX]] and [[Team Goals|Goals]] for details on the project timeline.
This is a test note for QA purposes.

View File

@@ -0,0 +1,54 @@
---
Is A: Theme
Description: Dark variant with deep navy tones
background: "#0f0f1a"
foreground: "#e0e0e0"
card: "#16162a"
popover: "#1e1e3a"
primary: "#155DFF"
primary-foreground: "#FFFFFF"
secondary: "#2a2a4a"
secondary-foreground: "#e0e0e0"
muted: "#1e1e3a"
muted-foreground: "#888888"
accent: "#2a2a4a"
accent-foreground: "#e0e0e0"
destructive: "#f44336"
border: "#2a2a4a"
input: "#2a2a4a"
ring: "#155DFF"
sidebar: "#1a1a2e"
sidebar-foreground: "#e0e0e0"
sidebar-border: "#2a2a4a"
sidebar-accent: "#2a2a4a"
text-primary: "#e0e0e0"
text-secondary: "#888888"
text-muted: "#666666"
text-heading: "#e0e0e0"
bg-primary: "#0f0f1a"
bg-sidebar: "#1a1a2e"
bg-hover: "#2a2a4a"
bg-hover-subtle: "#1e1e3a"
bg-selected: "#155DFF22"
border-primary: "#2a2a4a"
accent-blue: "#155DFF"
accent-green: "#00B38B"
accent-orange: "#D9730D"
accent-red: "#f44336"
accent-purple: "#A932FF"
accent-yellow: "#F0B100"
accent-blue-light: "#155DFF33"
accent-green-light: "#00B38B33"
accent-purple-light: "#A932FF33"
accent-red-light: "#f4433633"
accent-yellow-light: "#F0B10033"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14px
editor-font-size: 16
editor-line-height: 1.5
editor-max-width: 720
---
# Dark Theme
A dark theme with deep navy tones for comfortable night-time reading.

View File

@@ -0,0 +1,54 @@
---
Is A: Theme
Description: Light theme with warm, paper-like tones
background: "#FFFFFF"
foreground: "#37352F"
card: "#FFFFFF"
popover: "#FFFFFF"
primary: "#155DFF"
primary-foreground: "#FFFFFF"
secondary: "#EBEBEA"
secondary-foreground: "#37352F"
muted: "#F0F0EF"
muted-foreground: "#787774"
accent: "#EBEBEA"
accent-foreground: "#37352F"
destructive: "#E03E3E"
border: "#E9E9E7"
input: "#E9E9E7"
ring: "#155DFF"
sidebar: "#F7F6F3"
sidebar-foreground: "#37352F"
sidebar-border: "#E9E9E7"
sidebar-accent: "#EBEBEA"
text-primary: "#37352F"
text-secondary: "#787774"
text-muted: "#B4B4B4"
text-heading: "#37352F"
bg-primary: "#FFFFFF"
bg-sidebar: "#F7F6F3"
bg-hover: "#EBEBEA"
bg-hover-subtle: "#F0F0EF"
bg-selected: "#E8F4FE"
border-primary: "#E9E9E7"
accent-blue: "#155DFF"
accent-green: "#00B38B"
accent-orange: "#D9730D"
accent-red: "#E03E3E"
accent-purple: "#A932FF"
accent-yellow: "#F0B100"
accent-blue-light: "#155DFF14"
accent-green-light: "#00B38B14"
accent-purple-light: "#A932FF14"
accent-red-light: "#E03E3E14"
accent-yellow-light: "#F0B10014"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14px
editor-font-size: 16
editor-line-height: 1.5
editor-max-width: 720
---
# Default Theme
The default light theme for Laputa. Clean and warm, inspired by Notion.

View File

@@ -0,0 +1,54 @@
---
Is A: Theme
Description: High contrast, minimal chrome
background: "#FAFAFA"
foreground: "#111111"
card: "#FFFFFF"
popover: "#FFFFFF"
primary: "#000000"
primary-foreground: "#FFFFFF"
secondary: "#F0F0F0"
secondary-foreground: "#111111"
muted: "#F5F5F5"
muted-foreground: "#666666"
accent: "#F0F0F0"
accent-foreground: "#111111"
destructive: "#CC0000"
border: "#E0E0E0"
input: "#E0E0E0"
ring: "#000000"
sidebar: "#F5F5F5"
sidebar-foreground: "#111111"
sidebar-border: "#E0E0E0"
sidebar-accent: "#E8E8E8"
text-primary: "#111111"
text-secondary: "#666666"
text-muted: "#999999"
text-heading: "#111111"
bg-primary: "#FAFAFA"
bg-sidebar: "#F5F5F5"
bg-hover: "#EBEBEB"
bg-hover-subtle: "#F5F5F5"
bg-selected: "#00000014"
border-primary: "#E0E0E0"
accent-blue: "#000000"
accent-green: "#006600"
accent-orange: "#996600"
accent-red: "#CC0000"
accent-purple: "#660099"
accent-yellow: "#996600"
accent-blue-light: "#00000014"
accent-green-light: "#00660014"
accent-purple-light: "#66009914"
accent-red-light: "#CC000014"
accent-yellow-light: "#99660014"
font-family: "'SF Mono', 'Menlo', monospace"
font-size-base: 13px
editor-font-size: 15
editor-line-height: 1.6
editor-max-width: 680
---
# Minimal Theme
High contrast, minimal chrome. Monospace typography throughout.

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'coverage', 'src-tauri/resources/', 'src-tauri/target/']),
globalIgnores(['dist', 'coverage', 'src-tauri/resources/', 'src-tauri/target/', 'tools/']),
{
files: ['**/*.{ts,tsx}'],
extends: [

View File

@@ -1,21 +1,15 @@
#!/usr/bin/env node
/**
* Laputa MCP Server — provides vault operation tools for AI assistants.
* Laputa MCP Server — lightweight vault tools for AI agents.
*
* Usage:
* VAULT_PATH=/path/to/vault node index.js
* The agent has full shell access (bash, read, write, edit).
* These MCP tools provide Laputa-specific capabilities that
* native tools cannot replace:
*
* Tools:
* - open_note / read_note: Read a note by path
* - create_note: Create a new note with title and optional frontmatter
* - search_notes: Search notes by title or content
* - append_to_note: Append text to an existing note
* - edit_note_frontmatter: Merge a patch into a note's YAML frontmatter
* - delete_note: Delete a note file
* - link_notes: Add a title to an array property in a note's frontmatter
* - list_notes: List all notes, optionally filtered by type
* - vault_context: Get vault types and recent notes
* - ui_open_note / ui_open_tab / ui_highlight / ui_set_filter: UI actions
* - 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
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
@@ -23,70 +17,47 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import {
readNote, createNote, searchNotes, appendToNote,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
import { startUiBridge } from './ws-bridge.js'
import WebSocket from 'ws'
import { searchNotes, getNote, vaultContext } from './vault.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions.
// If the port is already in use (e.g. by the running Laputa app), continue
// without the bridge — vault tools still work via stdio MCP.
let uiBridge = null
startUiBridge(WS_UI_PORT).then((bridge) => {
uiBridge = bridge
})
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
// The bridge relays messages to all other clients (the React frontend).
let uiSocket = null
const RECONNECT_INTERVAL_MS = 3000
function connectUiBridge() {
try {
const ws = new WebSocket(WS_UI_URL)
ws.on('open', () => {
uiSocket = ws
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
})
ws.on('close', () => {
uiSocket = null
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
})
ws.on('error', () => {
// Silent — bridge may not be running yet, will retry
})
} catch {
setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
}
}
connectUiBridge()
function broadcastUiAction(action, payload) {
if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)
}
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
}
const TOOLS = [
{
name: 'open_note',
description: 'Open and read a note from the vault by its relative path',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
},
required: ['path'],
},
},
{
name: 'read_note',
description: 'Read the full content of a note',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
},
required: ['path'],
},
},
{
name: 'create_note',
description: 'Create a new note in the vault with a title and optional frontmatter',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path for the new note (e.g. "note/my-idea.md")' },
title: { type: 'string', description: 'Title of the note' },
is_a: { type: 'string', description: 'Entity type (Project, Note, Experiment, etc.)' },
},
required: ['path', 'title'],
},
},
{
name: 'search_notes',
description: 'Search notes in the vault by title or content',
description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.',
inputSchema: {
type: 'object',
properties: {
@@ -97,72 +68,24 @@ const TOOLS = [
},
},
{
name: 'append_to_note',
description: 'Append text to the end of an existing note',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
text: { type: 'string', description: 'Text to append' },
},
required: ['path', 'text'],
},
},
{
name: 'edit_note_frontmatter',
description: 'Merge a patch object into a note\'s YAML frontmatter',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' },
},
required: ['path', 'patch'],
},
},
{
name: 'delete_note',
description: 'Delete a note file from the vault',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note to delete' },
},
required: ['path'],
},
},
{
name: 'link_notes',
description: 'Add a target title to an array property in a note\'s frontmatter (e.g. add "Marco" to people: [])',
inputSchema: {
type: 'object',
properties: {
source_path: { type: 'string', description: 'Relative path to the source note' },
property: { type: 'string', description: 'Frontmatter property name (e.g. "people", "tags")' },
target_title: { type: 'string', description: 'Title to add to the array' },
},
required: ['source_path', 'property', 'target_title'],
},
},
{
name: 'list_notes',
description: 'List all notes in the vault, optionally filtered by type frontmatter field',
inputSchema: {
type: 'object',
properties: {
type_filter: { type: 'string', description: 'Filter by type frontmatter value' },
sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order (default: title)' },
},
},
},
{
name: 'vault_context',
description: 'Get vault context: unique entity types and 20 most recently modified notes',
name: 'get_vault_context',
description: 'Get vault orientation: entity types, total note count, top-level folders, and 20 most recently modified notes.',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'ui_open_note',
description: 'Open a note in the Laputa UI editor',
name: 'get_note',
description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
},
required: ['path'],
},
},
{
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.',
inputSchema: {
type: 'object',
properties: {
@@ -171,69 +94,13 @@ const TOOLS = [
required: ['path'],
},
},
{
name: 'ui_open_tab',
description: 'Open a note in a new tab in the Laputa UI',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
},
required: ['path'],
},
},
{
name: 'ui_highlight',
description: 'Highlight a UI element in the Laputa interface',
inputSchema: {
type: 'object',
properties: {
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'UI element to highlight' },
path: { type: 'string', description: 'Relative path to the note (optional)' },
},
required: ['element'],
},
},
{
name: 'ui_set_filter',
description: 'Set the sidebar filter to show notes of a specific type',
inputSchema: {
type: 'object',
properties: {
type: { type: 'string', description: 'Type to filter by' },
},
required: ['type'],
},
},
]
const TOOL_HANDLERS = {
open_note: handleReadNote,
read_note: handleReadNote,
create_note: handleCreateNote,
search_notes: handleSearchNotes,
append_to_note: handleAppendToNote,
edit_note_frontmatter: handleEditFrontmatter,
delete_note: handleDeleteNote,
link_notes: handleLinkNotes,
list_notes: handleListNotes,
vault_context: handleVaultContext,
ui_open_note: handleUiOpenNote,
ui_open_tab: handleUiOpenTab,
ui_highlight: handleUiHighlight,
ui_set_filter: handleUiSetFilter,
}
async function handleReadNote(args) {
const content = await readNote(VAULT_PATH, args.path)
return { content: [{ type: 'text', text: content }] }
}
async function handleCreateNote(args) {
const frontmatter = {}
if (args.is_a) frontmatter.is_a = args.is_a
const absPath = await createNote(VAULT_PATH, args.path, args.title, frontmatter)
return { content: [{ type: 'text', text: `Created note at ${absPath}` }] }
get_vault_context: handleVaultContext,
get_note: handleGetNote,
open_note: handleOpenNote,
}
async function handleSearchNotes(args) {
@@ -244,63 +111,25 @@ async function handleSearchNotes(args) {
return { content: [{ type: 'text', text }] }
}
async function handleAppendToNote(args) {
await appendToNote(VAULT_PATH, args.path, args.text)
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
}
async function handleEditFrontmatter(args) {
const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch)
return { content: [{ type: 'text', text: JSON.stringify(updated) }] }
}
async function handleDeleteNote(args) {
await deleteNote(VAULT_PATH, args.path)
return { content: [{ type: 'text', text: `Deleted ${args.path}` }] }
}
async function handleLinkNotes(args) {
const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title)
return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] }
}
async function handleListNotes(args) {
const notes = await listNotes(VAULT_PATH, args.type_filter, args.sort)
const text = notes.length === 0
? 'No notes found.'
: notes.map(n => `${n.title} (${n.path})${n.type ? ` [${n.type}]` : ''}`).join('\n')
return { content: [{ type: 'text', text }] }
}
async function handleVaultContext() {
const ctx = await vaultContext(VAULT_PATH)
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
}
function handleUiOpenNote(args) {
broadcastUiAction('open_note', { path: args.path })
return { content: [{ type: 'text', text: `Opening ${args.path} in UI` }] }
async function handleGetNote(args) {
const note = await getNote(VAULT_PATH, args.path)
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
}
function handleUiOpenTab(args) {
function handleOpenNote(args) {
broadcastUiAction('open_tab', { path: args.path })
return { content: [{ type: 'text', text: `Opening tab for ${args.path}` }] }
}
function handleUiHighlight(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleUiSetFilter(args) {
broadcastUiAction('set_filter', { type: args.type })
return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] }
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
}
// --- Server setup ---
const server = new Server(
{ name: 'laputa-mcp-server', version: '0.1.0' },
{ name: 'laputa-mcp-server', version: '0.2.0' },
{ capabilities: { tools: {} } },
)

View File

@@ -1,5 +1,6 @@
/**
* Vault operations — file I/O for Laputa markdown vault.
* Vault operations — read-only helpers for Laputa markdown vault.
* Write operations are handled by the agent's native bash/write/edit tools.
*/
import fs from 'node:fs/promises'
import path from 'node:path'
@@ -26,36 +27,20 @@ export async function findMarkdownFiles(dir) {
}
/**
* Read a note's content by path (absolute or relative to vault).
* Read a note with parsed frontmatter and content.
* @param {string} vaultPath
* @param {string} notePath
* @returns {Promise<string>}
* @returns {Promise<{path: string, frontmatter: Record<string, unknown>, content: string}>}
*/
export async function readNote(vaultPath, notePath) {
export async function getNote(vaultPath, notePath) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
return fs.readFile(absPath, 'utf-8')
}
/**
* Create a new note with optional frontmatter.
* @param {string} vaultPath
* @param {string} relativePath
* @param {string} title
* @param {Record<string, string>} [frontmatter]
* @returns {Promise<string>} The absolute path of the created file.
*/
export async function createNote(vaultPath, relativePath, title, frontmatter = {}) {
const absPath = path.join(vaultPath, relativePath)
await fs.mkdir(path.dirname(absPath), { recursive: true })
const fmEntries = { title, ...frontmatter }
const fmLines = Object.entries(fmEntries)
.map(([k, v]) => `${k}: ${v}`)
.join('\n')
const content = `---\n${fmLines}\n---\n\n# ${title}\n\n`
await fs.writeFile(absPath, content, 'utf-8')
return absPath
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
return {
path: path.relative(vaultPath, absPath),
frontmatter: parsed.data,
content: parsed.content.trim(),
}
}
/**
@@ -92,110 +77,14 @@ export async function searchNotes(vaultPath, query, limit = 10) {
}
/**
* Append text to the end of a note.
* Get vault context: unique types, note count, top-level folders, and 20 most recent notes.
* @param {string} vaultPath
* @param {string} notePath
* @param {string} text
* @returns {Promise<void>}
*/
export async function appendToNote(vaultPath, notePath, text) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
const current = await fs.readFile(absPath, 'utf-8')
const separator = current.endsWith('\n') ? '\n' : '\n\n'
await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8')
}
/**
* Merge a patch object into a note's YAML frontmatter.
* @param {string} vaultPath
* @param {string} notePath
* @param {Record<string, unknown>} patch
* @returns {Promise<Record<string, unknown>>} The updated frontmatter.
*/
export async function editNoteFrontmatter(vaultPath, notePath, patch) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
Object.assign(parsed.data, patch)
const updated = matter.stringify(parsed.content, parsed.data)
await fs.writeFile(absPath, updated, 'utf-8')
return parsed.data
}
/**
* Delete a note file.
* @param {string} vaultPath
* @param {string} notePath
* @returns {Promise<void>}
*/
export async function deleteNote(vaultPath, notePath) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
await fs.unlink(absPath)
}
/**
* Add a target title to an array property in a note's frontmatter.
* Creates the property as an array if it doesn't exist.
* @param {string} vaultPath
* @param {string} sourcePath
* @param {string} property
* @param {string} targetTitle
* @returns {Promise<string[]>} The updated array.
*/
export async function linkNotes(vaultPath, sourcePath, property, targetTitle) {
const absPath = path.isAbsolute(sourcePath) ? sourcePath : path.join(vaultPath, sourcePath)
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
const current = Array.isArray(parsed.data[property]) ? parsed.data[property] : []
if (!current.includes(targetTitle)) {
current.push(targetTitle)
}
parsed.data[property] = current
const updated = matter.stringify(parsed.content, parsed.data)
await fs.writeFile(absPath, updated, 'utf-8')
return current
}
/**
* List all notes in the vault, optionally filtered by type.
* @param {string} vaultPath
* @param {string} [typeFilter]
* @param {string} [sort] - 'title' or 'mtime' (default: 'title')
* @returns {Promise<Array<{path: string, title: string, type: string|null}>>}
*/
export async function listNotes(vaultPath, typeFilter, sort = 'title') {
const files = await findMarkdownFiles(vaultPath)
const notes = await Promise.all(files.map(async (filePath) => {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const relativePath = path.relative(vaultPath, filePath)
const title = parsed.data.title || extractTitle(raw, path.basename(filePath, '.md'))
const type = parsed.data.type || parsed.data.is_a || null
const stat = sort === 'mtime' ? await fs.stat(filePath) : null
return { path: relativePath, title, type, mtime: stat?.mtimeMs ?? 0 }
}))
const filtered = typeFilter
? notes.filter(n => n.type === typeFilter)
: notes
if (sort === 'mtime') {
filtered.sort((a, b) => b.mtime - a.mtime)
} else {
filtered.sort((a, b) => a.title.localeCompare(b.title))
}
return filtered.map(({ mtime: _mtime, ...rest }) => rest)
}
/**
* Get vault context: unique types and 20 most recent notes.
* @param {string} vaultPath
* @returns {Promise<{types: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
* @returns {Promise<{types: string[], noteCount: number, folders: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
*/
export async function vaultContext(vaultPath) {
const files = await findMarkdownFiles(vaultPath)
const typesSet = new Set()
const foldersSet = new Set()
const notesWithMtime = []
for (const filePath of files) {
@@ -203,9 +92,12 @@ export async function vaultContext(vaultPath) {
const parsed = matter(raw)
const type = parsed.data.type || parsed.data.is_a || null
if (type) typesSet.add(type)
const rel = path.relative(vaultPath, filePath)
const topFolder = rel.split(path.sep)[0]
if (topFolder !== rel) foldersSet.add(topFolder + '/')
const stat = await fs.stat(filePath)
notesWithMtime.push({
path: path.relative(vaultPath, filePath),
path: rel,
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
@@ -215,7 +107,13 @@ export async function vaultContext(vaultPath) {
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
return { types: [...typesSet].sort(), recentNotes, vaultPath }
return {
types: [...typesSet].sort(),
noteCount: files.length,
folders: [...foldersSet].sort(),
recentNotes,
vaultPath,
}
}
// --- Helpers ---

View File

@@ -22,8 +22,7 @@
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import {
readNote, createNote, searchNotes, appendToNote,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
getNote, searchNotes, vaultContext,
} from './vault.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
@@ -41,27 +40,16 @@ function broadcastUiAction(action, payload) {
}
}
function buildFrontmatter(args) {
const fm = {}
if (args.is_a) fm.is_a = args.is_a
return fm
}
const TOOL_HANDLERS = {
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
read_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
open_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch),
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })),
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title),
list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort),
vault_context: () => vaultContext(VAULT_PATH),
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
}
async function handleMessage(data) {
@@ -101,8 +89,15 @@ export function startUiBridge(port = WS_UI_PORT) {
httpServer.listen(port, () => {
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', () => {
wss.on('connection', (ws) => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
// Relay: when a client sends a message, broadcast to all OTHER clients.
// This allows the MCP stdio server (connected as a client) to reach the frontend.
ws.on('message', (raw) => {
for (const client of wss.clients) {
if (client !== ws && client.readyState === 1) client.send(raw.toString())
}
})
})
uiBridge = wss
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)

View File

@@ -13,6 +13,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test tests/smoke/",
"test:coverage": "vitest run --coverage",
"prepare": "husky"
},

View File

@@ -1,15 +1,18 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30000,
testDir: './tests/smoke',
timeout: 15_000,
retries: 0,
workers: 1,
use: {
baseURL: 'http://localhost:5173',
baseURL: process.env.BASE_URL || 'http://localhost:5201',
headless: true,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: 'pnpm dev',
port: 5173,
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5201'}`,
url: process.env.BASE_URL || 'http://localhost:5201',
reuseExistingServer: true,
},
})

155
scripts/bundle-qmd.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# Bundle qmd into a self-contained directory for Tauri resource embedding.
#
# Output: src-tauri/resources/qmd/
# qmd — compiled standalone binary
# node_modules/sqlite-vec/ — JS shim for sqlite-vec
# node_modules/sqlite-vec-darwin-arm64/ — native .dylib (arm64)
# node_modules/sqlite-vec-darwin-x64/ — native .dylib (x64)
# node_modules/node-llama-cpp/ — stub (keyword search only)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$SCRIPT_DIR/.."
OUT="$ROOT/src-tauri/resources/qmd"
# ---------- locate tools ----------
find_bun() {
for c in \
"$HOME/.bun/bin/bun" \
"/opt/homebrew/bin/bun" \
"/usr/local/bin/bun"; do
[[ -x "$c" ]] && { echo "$c"; return 0; }
done
command -v bun 2>/dev/null && return 0
return 1
}
BUN=$(find_bun) || { echo "ERROR: bun not found — install from https://bun.sh" >&2; exit 1; }
echo "Using bun: $BUN"
# ---------- locate qmd source ----------
# Prefer bundled source in tools/qmd/ (works in CI and dev),
# then fall back to globally installed qmd on dev machines.
QMD_SRC=""
for c in \
"$ROOT/tools/qmd" \
"$HOME/.bun/install/global/node_modules/qmd" \
"/opt/homebrew/lib/node_modules/qmd" \
"/usr/local/lib/node_modules/qmd"; do
[[ -f "$c/src/qmd.ts" ]] && { QMD_SRC="$c"; break; }
done
[[ -n "$QMD_SRC" ]] || { echo "ERROR: qmd source not found. tools/qmd/ is missing or incomplete." >&2; exit 1; }
echo "Using qmd source: $QMD_SRC"
# Install qmd dependencies if needed (for CI where node_modules don't exist yet)
if [[ ! -d "$QMD_SRC/node_modules" ]]; then
echo "Installing qmd dependencies..."
(cd "$QMD_SRC" && "$BUN" install --frozen-lockfile)
fi
# ---------- compile ----------
echo "Compiling qmd with bun build --compile..."
mkdir -p "$OUT"
(cd "$QMD_SRC" && "$BUN" build --compile \
"src/qmd.ts" \
--outfile "$OUT/qmd" \
--external node-llama-cpp \
--external sqlite-vec \
--external sqlite-vec-darwin-arm64 \
--external sqlite-vec-darwin-x64)
chmod +x "$OUT/qmd"
# ---------- bundle sqlite-vec ----------
echo "Bundling sqlite-vec native extensions..."
# Find sqlite-vec packages — prefer node_modules in QMD_SRC (after bun install),
# fall back to bun global cache for dev machines.
NM="$QMD_SRC/node_modules"
find_pkg() {
local pkg="$1"
# Check node_modules from bun install in QMD_SRC first
if [[ -d "$NM/$pkg" ]]; then
echo "$NM/$pkg"; return 0
fi
# Fall back to bun global cache
local cache_dir
cache_dir=$(find "$HOME/.bun/install/cache" -maxdepth 1 -name "${pkg}@*" -type d 2>/dev/null | head -1)
[[ -n "$cache_dir" ]] && echo "$cache_dir" && return 0
return 1
}
# sqlite-vec JS shim
SQLVEC_DIR=$(find_pkg "sqlite-vec") || { echo "ERROR: sqlite-vec not found" >&2; exit 1; }
mkdir -p "$OUT/node_modules/sqlite-vec"
cp "$SQLVEC_DIR/index.mjs" "$OUT/node_modules/sqlite-vec/index.mjs"
cp "$SQLVEC_DIR/package.json" "$OUT/node_modules/sqlite-vec/package.json"
[[ -f "$SQLVEC_DIR/index.cjs" ]] && cp "$SQLVEC_DIR/index.cjs" "$OUT/node_modules/sqlite-vec/index.cjs"
# sqlite-vec-darwin-arm64
ARM64_DIR=$(find_pkg "sqlite-vec-darwin-arm64") || true
if [[ -n "$ARM64_DIR" ]]; then
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-arm64"
cp "$ARM64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-arm64/vec0.dylib"
cp "$ARM64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-arm64/package.json"
echo " ✓ arm64 dylib"
fi
# sqlite-vec-darwin-x64
X64_DIR=$(find_pkg "sqlite-vec-darwin-x64") || true
if [[ -n "$X64_DIR" ]]; then
mkdir -p "$OUT/node_modules/sqlite-vec-darwin-x64"
cp "$X64_DIR/vec0.dylib" "$OUT/node_modules/sqlite-vec-darwin-x64/vec0.dylib"
cp "$X64_DIR/package.json" "$OUT/node_modules/sqlite-vec-darwin-x64/package.json"
echo " ✓ x64 dylib"
fi
# ---------- stub node-llama-cpp ----------
echo "Creating node-llama-cpp stub (keyword search only)..."
mkdir -p "$OUT/node_modules/node-llama-cpp"
cat > "$OUT/node_modules/node-llama-cpp/package.json" << 'PJSON'
{"name":"node-llama-cpp","version":"0.0.0-stub","type":"module","main":"index.js"}
PJSON
cat > "$OUT/node_modules/node-llama-cpp/index.js" << 'STUB'
// Stub: node-llama-cpp not bundled — semantic search unavailable, keyword search works.
const unavailable = (name) => (...args) => {
throw new Error(`${name}() unavailable: node-llama-cpp not bundled. Keyword search still works.`);
};
export const getLlama = unavailable("getLlama");
export const resolveModelFile = unavailable("resolveModelFile");
export class LlamaChatSession {
constructor() { throw new Error("LlamaChatSession unavailable"); }
}
export const LlamaLogLevel = { Error: 0, Warn: 1, Info: 2, Debug: 3 };
STUB
# ---------- code signing (macOS) ----------
# In CI (APPLE_SIGNING_IDENTITY set): sign with Developer ID + hardened runtime (required for notarization)
# In dev (no identity): ad-hoc sign to remove quarantine
if [[ "$(uname)" == "Darwin" ]] && command -v codesign &>/dev/null; then
SIGN_ID="${APPLE_SIGNING_IDENTITY:--}"
if [[ "$SIGN_ID" != "-" ]]; then
echo "Signing bundled binaries with Developer ID: $SIGN_ID"
SIGN_OPTS=(--force --sign "$SIGN_ID" --options runtime --timestamp)
else
echo "Ad-hoc signing bundled binaries (dev mode)..."
SIGN_OPTS=(--force --sign -)
fi
codesign "${SIGN_OPTS[@]}" "$OUT/qmd" 2>/dev/null && echo " ✓ qmd signed" || echo " ⚠ qmd signing failed (non-fatal)"
while IFS= read -r -d '' dylib; do
codesign "${SIGN_OPTS[@]}" "$dylib" 2>/dev/null && echo "$(basename "$dylib") signed" || echo "$(basename "$dylib") signing failed (non-fatal)"
done < <(find "$OUT/node_modules" -name "*.dylib" -print0)
fi
# ---------- summary ----------
echo ""
echo "qmd bundled → $OUT/"
du -sh "$OUT/qmd"
du -sh "$OUT/node_modules"
echo "Done."

View File

@@ -2,3 +2,7 @@
# will have compiled files and executables
/target/
/gen/schemas
# Generated by build scripts
/resources/mcp-server/
/resources/qmd/

View File

@@ -1,3 +1,13 @@
fn main() {
// Ensure resource directories exist for the Tauri build.
// These are gitignored and populated by scripts (bundle-qmd.sh, bundle-mcp-server.mjs).
// Without a placeholder, `tauri build` / `cargo test` fails if the scripts haven't run.
for dir in ["resources/qmd", "resources/mcp-server"] {
let path = std::path::Path::new(dir);
if !path.exists() {
std::fs::create_dir_all(path).ok();
std::fs::write(path.join(".placeholder"), "").ok();
}
}
tauri_build::build()
}

View File

@@ -19,6 +19,8 @@ pub enum ClaudeStreamEvent {
Init { session_id: String },
/// Incremental text chunk.
TextDelta { text: String },
/// Incremental thinking/reasoning chunk.
ThinkingDelta { text: String },
/// A tool call started (agent mode only).
ToolStart {
tool_name: String,
@@ -127,7 +129,7 @@ where
{
let bin = find_claude_binary()?;
let args = build_chat_args(&req);
run_claude_subprocess(&bin, &args, &mut emit)
run_claude_subprocess(&bin, &args, None, &mut emit)
}
/// Build CLI arguments for a chat stream request.
@@ -158,17 +160,18 @@ fn build_chat_args(req: &ChatStreamRequest) -> Vec<String> {
args
}
/// Spawn `claude -p` with MCP vault tools for an agent task and stream events.
/// Spawn `claude -p` with full tool access and MCP vault tools for an agent task.
pub fn run_agent_stream<F>(req: AgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(ClaudeStreamEvent),
{
let bin = find_claude_binary()?;
let args = build_agent_args(&req)?;
run_claude_subprocess(&bin, &args, &mut emit)
run_claude_subprocess(&bin, &args, Some(&req.vault_path), &mut emit)
}
/// Build CLI arguments for an agent stream request.
/// Native tools (bash, read, write, edit) are enabled by default — no `--tools ""`.
fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
let mcp_config = build_mcp_config(&req.vault_path)?;
@@ -179,8 +182,6 @@ fn build_agent_args(req: &AgentStreamRequest) -> Result<Vec<String>, String> {
"stream-json".into(),
"--verbose".into(),
"--include-partial-messages".into(),
"--tools".into(),
String::new(), // disable built-in tools; MCP tools remain
"--mcp-config".into(),
mcp_config,
"--dangerously-skip-permissions".into(),
@@ -227,15 +228,25 @@ struct StreamState {
}
/// Core subprocess runner shared by chat and agent modes.
fn run_claude_subprocess<F>(bin: &PathBuf, args: &[String], emit: &mut F) -> Result<String, String>
/// When `cwd` is `Some`, the subprocess starts with that working directory.
fn run_claude_subprocess<F>(
bin: &PathBuf,
args: &[String],
cwd: Option<&str>,
emit: &mut F,
) -> Result<String, String>
where
F: FnMut(ClaudeStreamEvent),
{
let mut child = Command::new(bin)
.args(args)
let mut cmd = Command::new(bin);
cmd.args(args)
.env_remove("CLAUDECODE") // prevent "nested session" guard
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let mut child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
@@ -402,6 +413,13 @@ where
});
}
}
Some("thinking_delta") => {
if let Some(text) = delta["thinking"].as_str() {
emit(ClaudeStreamEvent::ThinkingDelta {
text: text.to_string(),
});
}
}
Some("input_json_delta") => {
if let (Some(partial), Some(ref tid)) =
(delta["partial_json"].as_str(), &state.current_tool_id)
@@ -804,7 +822,7 @@ mod tests {
std::fs::write(&path, script).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut events = vec![];
let result = run_claude_subprocess(&path, args, &mut |e| events.push(e));
let result = run_claude_subprocess(&path, args, None, &mut |e| events.push(e));
(result, events)
}
@@ -955,6 +973,8 @@ mod tests {
assert!(args.contains(&"--dangerously-skip-permissions".to_string()));
assert!(args.contains(&"--no-session-persistence".to_string()));
assert!(!args.contains(&"--append-system-prompt".to_string()));
// Native tools must NOT be disabled
assert!(!args.contains(&"--tools".to_string()));
}
}
@@ -1026,7 +1046,7 @@ mod tests {
fn run_subprocess_spawn_failure() {
let fake_bin = PathBuf::from("/nonexistent/binary/path");
let mut events = vec![];
let result = run_claude_subprocess(&fake_bin, &[], &mut |e| events.push(e));
let result = run_claude_subprocess(&fake_bin, &[], None, &mut |e| events.push(e));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to spawn"));
}

661
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,661 @@
use std::borrow::Cow;
use crate::ai_chat::{AiChatRequest, AiChatResponse};
use crate::claude_cli::{
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
};
use crate::frontmatter::FrontmatterValue;
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use crate::indexing::{IndexStatus, IndexingProgress};
use crate::search::SearchResponse;
use crate::settings::Settings;
use crate::theme::{ThemeFile, VaultSettings};
use crate::vault::{RenameResult, VaultEntry};
use crate::vault_config::VaultConfig;
use crate::vault_list::VaultList;
use crate::{
frontmatter, git, github, indexing, menu, search, theme, vault, vault_config, vault_list,
};
/// 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));
}
}
Cow::Borrowed(path)
}
pub fn parse_build_label(version: &str) -> 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(),
}
}
pub fn emit_unavailable(app_handle: &tauri::AppHandle) {
use tauri::Emitter;
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "unavailable".to_string(),
current: 0,
total: 0,
done: true,
error: Some("qmd not available".to_string()),
},
);
}
// ── Vault commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path);
vault::scan_vault_cached(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn get_note_content(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::get_note_content(std::path::Path::new(path.as_ref()))
}
#[tauri::command]
pub fn save_note_content(path: String, content: String) -> Result<(), String> {
let path = expand_tilde(&path);
vault::save_note_content(&path, &content)
}
#[tauri::command]
pub fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note(&vault_path, &old_path, &new_title)
}
#[tauri::command]
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
vault::purge_trash(&vault_path)
}
#[tauri::command]
pub fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
pub fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
pub fn create_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(),
};
vault::create_getting_started_vault(&path)
}
#[tauri::command]
pub fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
pub fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
pub fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::copy_image_to_vault(&vault_path, &source_path)
}
// ── Frontmatter commands ────────────────────────────────────────────────────
#[tauri::command]
pub fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
pub fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
}
#[tauri::command]
pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(
&path,
"Trashed at",
FrontmatterValue::String(now.clone()),
)?;
count += 1;
}
Ok(count)
}
// ── Git commands ────────────────────────────────────────────────────────────
#[tauri::command]
pub fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_history(&vault_path, &path)
}
#[tauri::command]
pub fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_modified_files(&vault_path)
}
#[tauri::command]
pub fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff(&vault_path, &path)
}
#[tauri::command]
pub fn get_file_diff_at_commit(
vault_path: String,
path: String,
commit_hash: String,
) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[tauri::command]
pub fn get_vault_pulse(
vault_path: String,
limit: Option<usize>,
skip: Option<usize>,
) -> Result<Vec<PulseCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let limit = limit.unwrap_or(20);
let skip = skip.unwrap_or(0);
git::get_vault_pulse(&vault_path, limit, skip)
}
#[tauri::command]
pub fn git_commit(vault_path: String, message: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit(&vault_path, &message)
}
#[tauri::command]
pub fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_last_commit_info(&vault_path)
}
#[tauri::command]
pub fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path);
git::git_pull(&vault_path)
}
#[tauri::command]
pub fn get_conflict_files(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_files(&vault_path)
}
#[tauri::command]
pub fn get_conflict_mode(vault_path: String) -> String {
let vault_path = expand_tilde(&vault_path);
git::get_conflict_mode(&vault_path)
}
#[tauri::command]
pub fn git_resolve_conflict(
vault_path: String,
file: String,
strategy: String,
) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
git::git_resolve_conflict(&vault_path, &file, &strategy)
}
#[tauri::command]
pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit_conflict_resolution(&vault_path)
}
#[tauri::command]
pub fn git_push(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_push(&vault_path)
}
// ── GitHub commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
github::github_list_repos(&token).await
}
#[tauri::command]
pub async fn github_create_repo(
token: String,
name: String,
private: bool,
) -> Result<GithubRepo, String> {
github::github_create_repo(&token, &name, private).await
}
#[tauri::command]
pub fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
let local_path = expand_tilde(&local_path);
github::clone_repo(&url, &token, &local_path)
}
#[tauri::command]
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
github::github_device_flow_start().await
}
#[tauri::command]
pub async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
github::github_device_flow_poll(&device_code).await
}
#[tauri::command]
pub async fn github_get_user(token: String) -> Result<GitHubUser, String> {
github::github_get_user(&token).await
}
// ── AI / Claude commands ────────────────────────────────────────────────────
#[tauri::command]
pub async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
crate::ai_chat::send_chat(request).await
}
#[tauri::command]
pub fn check_claude_cli() -> ClaudeCliStatus {
crate::claude_cli::check_cli()
}
#[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}"))?
}
#[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}"))?
}
// ── Search & indexing commands ──────────────────────────────────────────────
#[tauri::command]
pub async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
#[tauri::command]
pub fn get_index_status(vault_path: String) -> IndexStatus {
let vault_path = expand_tilde(&vault_path);
indexing::check_index_status(&vault_path)
}
#[tauri::command]
pub async fn start_indexing(
app_handle: tauri::AppHandle,
vault_path: String,
) -> Result<(), String> {
use tauri::Emitter;
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
if indexing::find_qmd_binary().is_none() {
log::info!("qmd binary not found — attempting auto-install via bun");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "installing".to_string(),
current: 0,
total: 0,
done: false,
error: None,
},
);
match indexing::try_auto_install_qmd() {
Ok(()) if indexing::find_qmd_binary().is_some() => {
log::info!("qmd auto-installed successfully, proceeding with indexing");
}
Ok(()) => {
log::warn!("qmd auto-install reported success but binary still not found");
emit_unavailable(&app_handle);
return Err("qmd not available after install".to_string());
}
Err(e) => {
log::info!("qmd auto-install failed: {e}");
emit_unavailable(&app_handle);
return Err(format!("qmd not available: {e}"));
}
}
}
indexing::run_full_index(&vault_path, |progress| {
let _ = app_handle.emit("indexing-progress", &progress);
})
})
.await
.map_err(|e| format!("Indexing task failed: {e}"))?
}
#[tauri::command]
pub async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
.await
.map_err(|e| format!("Incremental index failed: {e}"))?
}
// ── MCP commands ────────────────────────────────────────────────────────────
#[tauri::command]
pub async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || crate::mcp::register_mcp(&vault_path))
.await
.map_err(|e| format!("Registration task failed: {e}"))?
}
#[tauri::command]
pub async fn check_mcp_status() -> Result<crate::mcp::McpStatus, String> {
tokio::task::spawn_blocking(crate::mcp::check_mcp_status)
.await
.map_err(|e| format!("MCP status check failed: {e}"))
}
// ── Theme commands ──────────────────────────────────────────────────────────
#[tauri::command]
pub fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
let vault_path = expand_tilde(&vault_path);
theme::list_themes(&vault_path)
}
#[tauri::command]
pub fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_theme(&vault_path, &theme_id)
}
#[tauri::command]
pub fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_vault_settings(&vault_path)
}
#[tauri::command]
pub fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::save_vault_settings(&vault_path, settings)
}
#[tauri::command]
pub fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::set_active_theme(&vault_path, theme_id.as_deref())
}
#[tauri::command]
pub fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_theme(&vault_path, source_id.as_deref())
}
#[tauri::command]
pub fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_vault_theme(&vault_path, name.as_deref())
}
#[tauri::command]
pub fn ensure_vault_themes(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::ensure_vault_themes(&vault_path)
}
#[tauri::command]
pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::restore_default_themes(&vault_path)
}
// ── Settings & config commands ──────────────────────────────────────────────
#[tauri::command]
pub fn get_build_number(app_handle: tauri::AppHandle) -> String {
let version = app_handle.package_info().version.to_string();
parse_build_label(&version)
}
#[tauri::command]
pub fn update_menu_state(
app_handle: tauri::AppHandle,
has_active_note: bool,
has_modified_files: Option<bool>,
has_conflicts: Option<bool>,
) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
if let Some(v) = has_modified_files {
menu::set_git_commit_items_enabled(&app_handle, v);
}
if let Some(v) = has_conflicts {
menu::set_git_conflict_items_enabled(&app_handle, v);
}
Ok(())
}
#[tauri::command]
pub fn get_settings() -> Result<Settings, String> {
crate::settings::get_settings()
}
#[tauri::command]
pub fn save_settings(settings: Settings) -> Result<(), String> {
crate::settings::save_settings(settings)
}
#[tauri::command]
pub fn load_vault_list() -> Result<VaultList, String> {
vault_list::load_vault_list()
}
#[tauri::command]
pub fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[tauri::command]
pub fn get_vault_config(vault_path: String) -> Result<VaultConfig, String> {
let vault_path = expand_tilde(&vault_path);
vault_config::get_vault_config(&vault_path)
}
#[tauri::command]
pub fn save_vault_config(vault_path: String, config: VaultConfig) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
vault_config::save_vault_config(&vault_path, config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_tilde_with_subpath() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~/Documents/vault");
assert_eq!(result, format!("{}/Documents/vault", home.display()));
}
#[test]
fn expand_tilde_alone() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~");
assert_eq!(result, home.to_string_lossy());
}
#[test]
fn expand_tilde_noop_for_absolute_path() {
let result = expand_tilde("/usr/local/bin");
assert_eq!(result, "/usr/local/bin");
}
#[test]
fn expand_tilde_noop_for_relative_path() {
let result = expand_tilde("some/relative/path");
assert_eq!(result, "some/relative/path");
}
#[test]
fn expand_tilde_noop_for_tilde_in_middle() {
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?");
}
#[test]
fn test_batch_archive_notes() {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("note.md");
std::fs::write(&note, "---\nStatus: Active\n---\n# Note\n").unwrap();
let result = batch_archive_notes(vec![note.to_str().unwrap().to_string()]);
assert_eq!(result.unwrap(), 1);
let content = std::fs::read_to_string(&note).unwrap();
assert!(content.contains("Archived: true"));
assert!(content.contains("Status: Active"));
}
#[test]
fn test_batch_trash_notes() {
let dir = tempfile::TempDir::new().unwrap();
let note = dir.path().join("note.md");
std::fs::write(&note, "---\nStatus: Active\n---\n# Note\n").unwrap();
let result = batch_trash_notes(vec![note.to_str().unwrap().to_string()]);
assert_eq!(result.unwrap(), 1);
let content = std::fs::read_to_string(&note).unwrap();
assert!(content.contains("Trashed: true"));
assert!(content.contains("Trashed at"));
}
#[test]
fn test_check_vault_exists_false() {
assert!(!check_vault_exists("/nonexistent/path/abc123".to_string()));
}
#[test]
fn test_get_default_vault_path_returns_ok() {
let result = get_default_vault_path();
assert!(result.is_ok());
}
}

View File

@@ -1,827 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// Value type for frontmatter updates
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum FrontmatterValue {
String(String),
Number(f64),
Bool(bool),
List(Vec<String>),
Null,
}
/// Characters that require a YAML string value to be quoted.
fn has_yaml_special_chars(s: &str) -> bool {
s.contains(':') || s.contains('#')
}
/// Check if a string starts with a YAML collection indicator (array or map).
fn starts_as_yaml_collection(s: &str) -> bool {
s.starts_with('[') || s.starts_with('{')
}
/// Check whether a YAML string value needs quoting to avoid ambiguity.
fn needs_yaml_quoting(s: &str) -> bool {
has_yaml_special_chars(s)
|| starts_as_yaml_collection(s)
|| matches!(s, "true" | "false" | "null")
|| s.parse::<f64>().is_ok()
}
/// Quote a string value for YAML, escaping internal double quotes.
fn quote_yaml_string(s: &str) -> String {
format!("\"{}\"", s.replace('\"', "\\\""))
}
/// Format a single YAML list item as ` - "value"`.
fn format_list_item(item: &str) -> String {
format!(" - {}", quote_yaml_string(item))
}
/// Format a multi-line string as a YAML block scalar (`|`).
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
fn format_block_scalar(s: &str) -> String {
let indented = s
.lines()
.map(|l| {
if l.is_empty() {
String::new()
} else {
format!(" {}", l)
}
})
.collect::<Vec<_>>()
.join("\n");
format!("|\n{}", indented)
}
/// Format a number for YAML (integers without decimal, floats with).
fn format_yaml_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
impl FrontmatterValue {
pub fn to_yaml_value(&self) -> String {
match self {
FrontmatterValue::String(s) => {
if s.contains('\n') {
format_block_scalar(s)
} else if needs_yaml_quoting(s) {
quote_yaml_string(s)
} else {
s.clone()
}
}
FrontmatterValue::Number(n) => format_yaml_number(*n),
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
FrontmatterValue::List(items) => items
.iter()
.map(|item| format_list_item(item))
.collect::<Vec<_>>()
.join("\n"),
FrontmatterValue::Null => "null".to_string(),
}
}
}
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
fn needs_key_quoting(key: &str) -> bool {
key.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
}
/// Format a key for YAML output (quote if necessary)
pub fn format_yaml_key(key: &str) -> String {
if needs_key_quoting(key) {
format!("\"{}\"", key)
} else {
key.to_string()
}
}
/// 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
}
/// Format a key-value pair as one or more YAML lines.
fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
let yaml_key = format_yaml_key(key);
let yaml_value = value.to_yaml_value();
if yaml_value.starts_with("|\n") {
// Block scalar: key and indicator on the same line, content follows
vec![format!("{}: {}", yaml_key, yaml_value)]
} else if yaml_value.contains('\n') {
vec![format!("{}:", yaml_key), yaml_value]
} else {
vec![format!("{}: {}", yaml_key, yaml_value)]
}
}
/// 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')
}
/// 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));
}
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)
}
/// 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;
while i < lines.len() {
if !line_is_key(lines[i], key) {
new_lines.push(lines[i].to_string());
i += 1;
continue;
}
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, value) {
new_lines.extend(format_yaml_field(key, v));
}
new_lines
}
/// Internal function to update frontmatter content
pub fn update_frontmatter_content(
content: &str,
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 (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))
}
/// Helper to read a file, apply a frontmatter transformation, and write back.
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
where
F: FnOnce(&str) -> Result<String, String>,
{
let file_path = Path::new(path);
if !file_path.exists() {
return Err(format!("File does not exist: {}", path));
}
let content =
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
let updated = transform(&content)?;
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
Ok(updated)
}
/// Update a single frontmatter property in a markdown file.
pub fn update_frontmatter(
path: &str,
key: &str,
value: FrontmatterValue,
) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, Some(value.clone()))
})
}
/// Delete a frontmatter property from a markdown file.
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, None)
})
}
#[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_format_yaml_key_simple() {
assert_eq!(format_yaml_key("Status"), "Status");
assert_eq!(format_yaml_key("is_a"), "is_a");
}
#[test]
fn test_format_yaml_key_with_spaces() {
assert_eq!(format_yaml_key("Is A"), "\"Is A\"");
assert_eq!(format_yaml_key("Created at"), "\"Created at\"");
}
// --- to_yaml_value quoting tests ---
#[test]
fn test_to_yaml_value_string_needs_quoting_colon() {
let v = FrontmatterValue::String("key: value".to_string());
assert_eq!(v.to_yaml_value(), "\"key: value\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_hash() {
let v = FrontmatterValue::String("has # comment".to_string());
assert_eq!(v.to_yaml_value(), "\"has # comment\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_bracket() {
let v = FrontmatterValue::String("[array-like]".to_string());
assert_eq!(v.to_yaml_value(), "\"[array-like]\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_brace() {
let v = FrontmatterValue::String("{object-like}".to_string());
assert_eq!(v.to_yaml_value(), "\"{object-like}\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_bool_like() {
assert_eq!(
FrontmatterValue::String("true".to_string()).to_yaml_value(),
"\"true\""
);
assert_eq!(
FrontmatterValue::String("false".to_string()).to_yaml_value(),
"\"false\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_null_like() {
assert_eq!(
FrontmatterValue::String("null".to_string()).to_yaml_value(),
"\"null\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_number_like() {
assert_eq!(
FrontmatterValue::String("42".to_string()).to_yaml_value(),
"\"42\""
);
assert_eq!(
FrontmatterValue::String("3.14".to_string()).to_yaml_value(),
"\"3.14\""
);
}
#[test]
fn test_to_yaml_value_string_plain() {
let v = FrontmatterValue::String("Hello World".to_string());
assert_eq!(v.to_yaml_value(), "Hello World");
}
#[test]
fn test_to_yaml_value_number_integer() {
let v = FrontmatterValue::Number(42.0);
assert_eq!(v.to_yaml_value(), "42");
}
#[test]
fn test_to_yaml_value_number_float() {
let v = FrontmatterValue::Number(3.14);
assert_eq!(v.to_yaml_value(), "3.14");
}
#[test]
fn test_to_yaml_value_null() {
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
}
#[test]
fn test_to_yaml_value_empty_list() {
let v = FrontmatterValue::List(vec![]);
assert_eq!(v.to_yaml_value(), "[]");
}
#[test]
fn test_to_yaml_value_list_with_colon() {
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
}
// --- update_frontmatter_content additional type tests ---
#[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"));
}
// --- delete non-existent key (should be no-op) ---
#[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);
}
// --- line_is_key tests ---
#[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() {
// "StatusBar" should not match key "Status"
assert!(!line_is_key("StatusBar: value", "Status"));
}
// --- with_frontmatter error cases ---
#[test]
fn test_with_frontmatter_file_not_found() {
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
// --- roundtrip tests ---
#[test]
fn test_roundtrip_update_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
// Parse back with gray_matter
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_update_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec![
"A".to_string(),
"B".to_string(),
])),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let aliases = map.get("aliases").unwrap();
if let gray_matter::Pod::Array(arr) = aliases {
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].as_string().unwrap(), "A");
assert_eq!(arr[1].as_string().unwrap(), "B");
} else {
panic!("Expected array");
}
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_add_then_delete() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let with_owner = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(with_owner.contains("Owner: Luca"));
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
assert!(!without_owner.contains("Owner"));
assert!(without_owner.contains("Status: Draft"));
}
// --- format_yaml_key additional tests ---
#[test]
fn test_format_yaml_key_with_colon() {
assert_eq!(format_yaml_key("key:value"), "\"key:value\"");
}
#[test]
fn test_format_yaml_key_with_hash() {
assert_eq!(format_yaml_key("has#tag"), "\"has#tag\"");
}
#[test]
fn test_format_yaml_key_with_period() {
assert_eq!(format_yaml_key("key.name"), "\"key.name\"");
}
// --- split_frontmatter / empty frontmatter edge cases ---
#[test]
fn test_split_frontmatter_empty_block() {
// ---\n---\n (no fields between opening and closing ---)
let result = split_frontmatter("---\n---\n");
assert!(
result.is_ok(),
"split_frontmatter should handle empty frontmatter block"
);
let (fm, rest) = result.unwrap();
assert_eq!(fm, "");
assert_eq!(rest, "\n");
}
#[test]
fn test_split_frontmatter_empty_block_no_trailing_newline() {
// ---\n--- (no trailing newline)
let result = split_frontmatter("---\n---");
assert!(
result.is_ok(),
"split_frontmatter should handle empty frontmatter without trailing newline"
);
}
#[test]
fn test_split_frontmatter_empty_block_with_body() {
// ---\n---\n\n# Title\n
let result = split_frontmatter("---\n---\n\n# Title\n");
assert!(
result.is_ok(),
"split_frontmatter should handle empty frontmatter with body"
);
let (fm, rest) = result.unwrap();
assert_eq!(fm, "");
assert!(rest.contains("# Title"));
}
#[test]
fn test_update_frontmatter_empty_block() {
let content = "---\n---\n\n# Test\n";
let result = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New Title".to_string())),
);
assert!(
result.is_ok(),
"update_frontmatter_content should handle empty frontmatter block"
);
let updated = result.unwrap();
assert!(updated.contains("title: New Title"));
}
// --- block scalar (multi-line string) tests ---
#[test]
fn test_to_yaml_value_multiline_uses_block_scalar() {
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
let yaml = v.to_yaml_value();
assert!(yaml.starts_with("|\n"));
assert!(yaml.contains(" line 1"));
assert!(yaml.contains(" line 2"));
}
#[test]
fn test_format_yaml_field_block_scalar() {
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
let lines = format_yaml_field("template", &v);
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("template: |\n"));
assert!(lines[0].contains(" ## Objective"));
assert!(lines[0].contains(" ## Timeline"));
}
#[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(" ## Timeline"));
assert!(updated.contains("type: Type"));
}
#[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 new_template = "## New\n\n## Content";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(new_template.to_string())),
)
.unwrap();
assert!(updated.contains(" ## New"));
assert!(updated.contains(" ## Content"));
assert!(!updated.contains("## Old"));
assert!(!updated.contains("## Stuff"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_delete_frontmatter_block_scalar() {
let content =
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
let updated = update_frontmatter_content(content, "template", None).unwrap();
assert!(!updated.contains("template"));
assert!(!updated.contains("## Heading"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_roundtrip_block_scalar() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
// Parse back with gray_matter
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let roundtripped = map.get("template").unwrap().as_string().unwrap();
assert!(roundtripped.contains("## Objective"));
assert!(roundtripped.contains("## Timeline"));
assert!(roundtripped.contains("Describe the goal."));
} else {
panic!("Expected hash");
}
}
#[test]
fn test_update_frontmatter_no_body_after_closing() {
// Frontmatter with title, no body after closing ---
let content = "---\ntitle: Old\n---\n";
let result = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New".to_string())),
);
assert!(result.is_ok());
let updated = result.unwrap();
assert!(updated.contains("title: New"));
assert!(!updated.contains("title: Old"));
}
}

View File

@@ -0,0 +1,207 @@
mod ops;
mod yaml;
use std::fs;
use std::path::Path;
pub use ops::update_frontmatter_content;
pub use yaml::{format_yaml_key, FrontmatterValue};
/// Helper to read a file, apply a frontmatter transformation, and write back.
pub fn with_frontmatter<F>(path: &str, transform: F) -> Result<String, String>
where
F: FnOnce(&str) -> Result<String, String>,
{
let file_path = Path::new(path);
if !file_path.exists() {
return Err(format!("File does not exist: {}", path));
}
let content =
fs::read_to_string(file_path).map_err(|e| format!("Failed to read {}: {}", path, e))?;
let updated = transform(&content)?;
fs::write(file_path, &updated).map_err(|e| format!("Failed to write {}: {}", path, e))?;
Ok(updated)
}
/// Update a single frontmatter property in a markdown file.
pub fn update_frontmatter(
path: &str,
key: &str,
value: FrontmatterValue,
) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, Some(value.clone()))
})
}
/// Delete a frontmatter property from a markdown file.
pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, String> {
with_frontmatter(path, |content| {
update_frontmatter_content(content, key, None)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with_frontmatter_file_not_found() {
let result = with_frontmatter("/nonexistent/path/file.md", |c| Ok(c.to_string()));
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_roundtrip_update_string() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"Status",
Some(FrontmatterValue::String("Active".to_string())),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
assert_eq!(map.get("Status").unwrap().as_string().unwrap(), "Active");
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_update_list() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let updated = update_frontmatter_content(
content,
"aliases",
Some(FrontmatterValue::List(vec![
"A".to_string(),
"B".to_string(),
])),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let aliases = map.get("aliases").unwrap();
if let gray_matter::Pod::Array(arr) = aliases {
assert_eq!(arr.len(), 2);
assert_eq!(arr[0].as_string().unwrap(), "A");
assert_eq!(arr[1].as_string().unwrap(), "B");
} else {
panic!("Expected array");
}
} else {
panic!("Expected hash");
}
}
#[test]
fn test_roundtrip_add_then_delete() {
let content = "---\nStatus: Draft\n---\n# Test\n";
let with_owner = update_frontmatter_content(
content,
"Owner",
Some(FrontmatterValue::String("Luca".to_string())),
)
.unwrap();
assert!(with_owner.contains("Owner: Luca"));
let without_owner = update_frontmatter_content(&with_owner, "Owner", None).unwrap();
assert!(!without_owner.contains("Owner"));
assert!(without_owner.contains("Status: Draft"));
}
#[test]
fn test_update_frontmatter_empty_block() {
let content = "---\n---\n\n# Test\n";
let result = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New Title".to_string())),
);
assert!(result.is_ok());
assert!(result.unwrap().contains("title: New Title"));
}
#[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"));
}
#[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"));
}
#[test]
fn test_delete_frontmatter_block_scalar() {
let content =
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
let updated = update_frontmatter_content(content, "template", None).unwrap();
assert!(!updated.contains("template"));
assert!(updated.contains("color: green"));
}
#[test]
fn test_update_frontmatter_no_body_after_closing() {
let content = "---\ntitle: Old\n---\n";
let updated = update_frontmatter_content(
content,
"title",
Some(FrontmatterValue::String("New".to_string())),
)
.unwrap();
assert!(updated.contains("title: New"));
assert!(!updated.contains("title: Old"));
}
#[test]
fn test_roundtrip_block_scalar() {
let content = "---\ntype: Type\n---\n# Project\n";
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
let updated = update_frontmatter_content(
content,
"template",
Some(FrontmatterValue::String(template.to_string())),
)
.unwrap();
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
let parsed = matter.parse(&updated);
let data = parsed.data.unwrap();
if let gray_matter::Pod::Hash(map) = data {
let roundtripped = map.get("template").unwrap().as_string().unwrap();
assert!(roundtripped.contains("## Objective"));
assert!(roundtripped.contains("## Timeline"));
assert!(roundtripped.contains("Describe the goal."));
} else {
panic!("Expected hash");
}
}
}

View File

@@ -0,0 +1,331 @@
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')
}
/// 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));
}
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)
}
/// 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;
while i < lines.len() {
if !line_is_key(lines[i], key) {
new_lines.push(lines[i].to_string());
i += 1;
continue;
}
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, value) {
new_lines.extend(format_yaml_field(key, v));
}
new_lines
}
/// Internal function to update frontmatter content
pub fn update_frontmatter_content(
content: &str,
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 (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"));
}
}

View File

@@ -0,0 +1,262 @@
use serde::{Deserialize, Serialize};
/// Value type for frontmatter updates
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum FrontmatterValue {
String(String),
Number(f64),
Bool(bool),
List(Vec<String>),
Null,
}
/// Characters that require a YAML string value to be quoted.
fn has_yaml_special_chars(s: &str) -> bool {
s.contains(':') || s.contains('#')
}
/// Check if a string starts with a YAML collection indicator (array or map).
fn starts_as_yaml_collection(s: &str) -> bool {
s.starts_with('[') || s.starts_with('{')
}
/// Check whether a YAML string value needs quoting to avoid ambiguity.
fn needs_yaml_quoting(s: &str) -> bool {
has_yaml_special_chars(s)
|| starts_as_yaml_collection(s)
|| matches!(s, "true" | "false" | "null")
|| s.parse::<f64>().is_ok()
}
/// Quote a string value for YAML, escaping internal double quotes.
fn quote_yaml_string(s: &str) -> String {
format!("\"{}\"", s.replace('\"', "\\\""))
}
/// Format a single YAML list item as ` - "value"`.
fn format_list_item(item: &str) -> String {
format!(" - {}", quote_yaml_string(item))
}
/// Format a multi-line string as a YAML block scalar (`|`).
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
fn format_block_scalar(s: &str) -> String {
let indented = s
.lines()
.map(|l| {
if l.is_empty() {
String::new()
} else {
format!(" {}", l)
}
})
.collect::<Vec<_>>()
.join("\n");
format!("|\n{}", indented)
}
/// Format a number for YAML (integers without decimal, floats with).
fn format_yaml_number(n: f64) -> String {
if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
impl FrontmatterValue {
pub fn to_yaml_value(&self) -> String {
match self {
FrontmatterValue::String(s) => {
if s.contains('\n') {
format_block_scalar(s)
} else if needs_yaml_quoting(s) {
quote_yaml_string(s)
} else {
s.clone()
}
}
FrontmatterValue::Number(n) => format_yaml_number(*n),
FrontmatterValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
FrontmatterValue::List(items) if items.is_empty() => "[]".to_string(),
FrontmatterValue::List(items) => items
.iter()
.map(|item| format_list_item(item))
.collect::<Vec<_>>()
.join("\n"),
FrontmatterValue::Null => "null".to_string(),
}
}
}
/// Check whether a YAML key needs quoting (contains spaces, special chars, etc.).
fn needs_key_quoting(key: &str) -> bool {
key.chars()
.any(|c| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
}
/// Format a key for YAML output (quote if necessary)
pub fn format_yaml_key(key: &str) -> String {
if needs_key_quoting(key) {
format!("\"{}\"", key)
} else {
key.to_string()
}
}
/// Format a key-value pair as one or more YAML lines.
pub fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
let yaml_key = format_yaml_key(key);
let yaml_value = value.to_yaml_value();
if yaml_value.starts_with("|\n") {
// Block scalar: key and indicator on the same line, content follows
vec![format!("{}: {}", yaml_key, yaml_value)]
} else if yaml_value.contains('\n') {
vec![format!("{}:", yaml_key), yaml_value]
} else {
vec![format!("{}: {}", yaml_key, yaml_value)]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_yaml_value_string_needs_quoting_colon() {
let v = FrontmatterValue::String("key: value".to_string());
assert_eq!(v.to_yaml_value(), "\"key: value\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_hash() {
let v = FrontmatterValue::String("has # comment".to_string());
assert_eq!(v.to_yaml_value(), "\"has # comment\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_bracket() {
let v = FrontmatterValue::String("[array-like]".to_string());
assert_eq!(v.to_yaml_value(), "\"[array-like]\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_brace() {
let v = FrontmatterValue::String("{object-like}".to_string());
assert_eq!(v.to_yaml_value(), "\"{object-like}\"");
}
#[test]
fn test_to_yaml_value_string_needs_quoting_bool_like() {
assert_eq!(
FrontmatterValue::String("true".to_string()).to_yaml_value(),
"\"true\""
);
assert_eq!(
FrontmatterValue::String("false".to_string()).to_yaml_value(),
"\"false\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_null_like() {
assert_eq!(
FrontmatterValue::String("null".to_string()).to_yaml_value(),
"\"null\""
);
}
#[test]
fn test_to_yaml_value_string_needs_quoting_number_like() {
assert_eq!(
FrontmatterValue::String("42".to_string()).to_yaml_value(),
"\"42\""
);
assert_eq!(
FrontmatterValue::String("3.14".to_string()).to_yaml_value(),
"\"3.14\""
);
}
#[test]
fn test_to_yaml_value_string_plain() {
let v = FrontmatterValue::String("Hello World".to_string());
assert_eq!(v.to_yaml_value(), "Hello World");
}
#[test]
fn test_to_yaml_value_number_integer() {
let v = FrontmatterValue::Number(42.0);
assert_eq!(v.to_yaml_value(), "42");
}
#[test]
fn test_to_yaml_value_number_float() {
let v = FrontmatterValue::Number(3.14);
assert_eq!(v.to_yaml_value(), "3.14");
}
#[test]
fn test_to_yaml_value_null() {
assert_eq!(FrontmatterValue::Null.to_yaml_value(), "null");
}
#[test]
fn test_to_yaml_value_empty_list() {
let v = FrontmatterValue::List(vec![]);
assert_eq!(v.to_yaml_value(), "[]");
}
#[test]
fn test_to_yaml_value_list_with_colon() {
let v = FrontmatterValue::List(vec!["key: value".to_string()]);
assert_eq!(v.to_yaml_value(), " - \"key: value\"");
}
#[test]
fn test_to_yaml_value_multiline_uses_block_scalar() {
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
let yaml = v.to_yaml_value();
assert!(yaml.starts_with("|\n"));
assert!(yaml.contains(" line 1"));
assert!(yaml.contains(" line 2"));
}
#[test]
fn test_format_yaml_key_simple() {
assert_eq!(format_yaml_key("Status"), "Status");
assert_eq!(format_yaml_key("is_a"), "is_a");
}
#[test]
fn test_format_yaml_key_with_spaces() {
assert_eq!(format_yaml_key("Is A"), "\"Is A\"");
assert_eq!(format_yaml_key("Created at"), "\"Created at\"");
}
#[test]
fn test_format_yaml_key_with_colon() {
assert_eq!(format_yaml_key("key:value"), "\"key:value\"");
}
#[test]
fn test_format_yaml_key_with_hash() {
assert_eq!(format_yaml_key("has#tag"), "\"has#tag\"");
}
#[test]
fn test_format_yaml_key_with_period() {
assert_eq!(format_yaml_key("key.name"), "\"key.name\"");
}
#[test]
fn test_format_yaml_field_block_scalar() {
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
let lines = format_yaml_field("template", &v);
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("template: |\n"));
assert!(lines[0].contains(" ## Objective"));
assert!(lines[0].contains(" ## Timeline"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
use std::path::Path;
use std::process::Command;
/// Commit all changes with a message.
pub fn git_commit(vault_path: &str, message: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Stage all changes
let add = Command::new("git")
.args(["add", "-A"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git add: {}", e))?;
if !add.status.success() {
let stderr = String::from_utf8_lossy(&add.stderr);
return Err(format!("git add failed: {}", stderr));
}
// Commit
let commit = Command::new("git")
.args(["commit", "-m", message])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?;
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
let stdout = String::from_utf8_lossy(&commit.stdout);
// git writes "nothing to commit" to stdout, not stderr
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
return Err(format!("git commit failed: {}", detail.trim()));
}
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_git_commit() {
let dir = setup_git_repo();
let vault = dir.path();
fs::write(vault.join("commit-test.md"), "# Test\n").unwrap();
let result = git_commit(vault.to_str().unwrap(), "Test commit");
assert!(result.is_ok());
// Verify the commit exists
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(vault)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Test commit"));
}
#[test]
fn test_commit_nothing_to_commit_returns_error() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
// Create and commit, so working tree is clean
fs::write(vault.join("clean.md"), "# Clean\n").unwrap();
git_commit(vp, "initial").unwrap();
// Committing again with no changes should fail
let result = git_commit(vp, "nothing here");
assert!(result.is_err(), "Commit should fail when nothing to commit");
assert!(
result.unwrap_err().contains("nothing to commit"),
"Error should mention 'nothing to commit'"
);
}
}

View File

@@ -0,0 +1,366 @@
use std::path::Path;
use std::process::Command;
use super::run_git;
/// List files with merge conflicts (unmerged paths).
///
/// Uses `git ls-files --unmerged` instead of `git diff --diff-filter=U` because
/// ls-files reliably detects unmerged index entries even when the merge state is
/// stale (e.g. after a reboot or when MERGE_HEAD is missing).
pub fn get_conflict_files(vault_path: &str) -> Result<Vec<String>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["ls-files", "--unmerged"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to check conflicts: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout);
// Each unmerged file appears multiple times (once per stage: base/ours/theirs).
// Format: "<mode> <hash> <stage>\t<path>"
let mut files: Vec<String> = stdout
.lines()
.filter_map(|line| line.split('\t').nth(1).map(|s| s.to_string()))
.collect();
files.sort();
files.dedup();
Ok(files)
}
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
/// then stage the result.
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let checkout_flag = match strategy {
"ours" => "--ours",
"theirs" => "--theirs",
_ => {
return Err(format!(
"Invalid strategy '{}': must be 'ours' or 'theirs'",
strategy
))
}
};
run_git(vault, &["checkout", checkout_flag, "--", file])?;
run_git(vault, &["add", "--", file])?;
Ok(())
}
/// Check whether a rebase is currently in progress.
pub fn is_rebase_in_progress(vault_path: &str) -> bool {
let vault = Path::new(vault_path);
let git_dir = vault.join(".git");
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
}
/// Check whether a merge is currently in progress.
pub fn is_merge_in_progress(vault_path: &str) -> bool {
Path::new(vault_path)
.join(".git")
.join("MERGE_HEAD")
.exists()
}
/// Returns the current conflict mode: "rebase", "merge", or "none".
pub fn get_conflict_mode(vault_path: &str) -> String {
if is_rebase_in_progress(vault_path) {
"rebase".to_string()
} else if is_merge_in_progress(vault_path) {
"merge".to_string()
} else {
"none".to_string()
}
}
/// Commit after all conflicts have been resolved.
/// Detects whether the repo is in a merge or rebase state and uses the
/// appropriate command (`git commit` vs `git rebase --continue`).
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Verify no remaining conflicts
let remaining = get_conflict_files(vault_path)?;
if !remaining.is_empty() {
return Err(format!(
"Cannot commit: {} file(s) still have unresolved conflicts",
remaining.len()
));
}
let mode = get_conflict_mode(vault_path);
let output = match mode.as_str() {
"rebase" => Command::new("git")
.args(["rebase", "--continue"])
.env("GIT_EDITOR", "true")
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git rebase --continue: {}", e))?,
_ => Command::new("git")
.args(["commit", "-m", "Resolve merge conflicts"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?,
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
let cmd_name = if mode == "rebase" {
"git rebase --continue"
} else {
"git commit"
};
return Err(format!("{} failed: {}", cmd_name, detail.trim()));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use crate::git::{git_commit, git_pull, git_push};
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn test_get_conflict_files_empty_when_clean() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
let conflicts = get_conflict_files(vp).unwrap();
assert!(conflicts.is_empty());
}
#[test]
fn test_resolve_conflict_invalid_strategy() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid strategy"));
}
#[test]
fn test_conflict_mode_none_for_clean_repo() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
assert_eq!(get_conflict_mode(vp), "none");
assert!(!is_rebase_in_progress(vp));
assert!(!is_merge_in_progress(vp));
}
/// Set up a pair of clones that have a merge conflict on the same file.
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) {
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
let vp_a = clone_a_dir.path().to_str().unwrap();
let vp_b = clone_b_dir.path().to_str().unwrap();
// A creates the file and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
git_commit(vp_a, "create conflict.md").unwrap();
git_push(vp_a).unwrap();
// B pulls to get the file
git_pull(vp_b).unwrap();
// A modifies and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
git_commit(vp_a, "A's change").unwrap();
git_push(vp_a).unwrap();
// B modifies the same file locally and commits
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
// B pulls — this causes a merge conflict
let result = git_pull(vp_b).unwrap();
assert_eq!(result.status, "conflict");
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_resolve_conflict_ours() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(conflicts.contains(&"conflict.md".to_string()));
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version B\n");
}
#[test]
fn test_resolve_conflict_theirs() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version A\n");
}
#[test]
fn test_commit_conflict_resolution() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(clone_b.path())
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Resolve merge conflicts"));
}
#[test]
fn test_commit_conflict_resolution_fails_with_unresolved() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("still have unresolved conflicts"));
}
#[test]
fn test_conflict_mode_merge_during_merge_conflict() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "merge");
assert!(is_merge_in_progress(vp_b));
assert!(!is_rebase_in_progress(vp_b));
}
#[test]
fn test_commit_conflict_resolution_merge_mode() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "merge");
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
assert_eq!(get_conflict_mode(vp_b), "none");
}
/// Set up a rebase conflict: clone_b has diverged from origin and
/// `git pull --rebase` causes a conflict.
fn setup_rebase_conflict_pair() -> (TempDir, TempDir, TempDir) {
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
let vp_a = clone_a_dir.path().to_str().unwrap();
let vp_b = clone_b_dir.path().to_str().unwrap();
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
git_commit(vp_a, "create conflict.md").unwrap();
git_push(vp_a).unwrap();
git_pull(vp_b).unwrap();
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
git_commit(vp_a, "A's change").unwrap();
git_push(vp_a).unwrap();
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
let output = Command::new("git")
.args(["pull", "--rebase"])
.current_dir(clone_b_dir.path())
.output()
.unwrap();
assert!(
!output.status.success(),
"Expected rebase conflict, but pull succeeded"
);
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_conflict_mode_rebase_during_rebase_conflict() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
assert!(is_rebase_in_progress(vp_b));
assert!(!is_merge_in_progress(vp_b));
}
#[test]
fn test_get_conflict_files_during_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(
conflicts.contains(&"conflict.md".to_string()),
"Should detect conflict.md during rebase, got: {:?}",
conflicts
);
}
#[test]
fn test_resolve_and_continue_rebase() {
let (_bare, _clone_a, clone_b) = setup_rebase_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
assert_eq!(get_conflict_mode(vp_b), "rebase");
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok(), "rebase --continue failed: {:?}", result);
assert_eq!(get_conflict_mode(vp_b), "none");
}
}

View File

@@ -0,0 +1,349 @@
use std::path::Path;
use std::process::Command;
use super::GitCommit;
/// Get git log history for a specific file in the vault.
pub fn get_file_history(vault_path: &str, file_path: &str) -> Result<Vec<GitCommit>, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
let output = Command::new("git")
.args([
"log",
"--format=%H|%h|%an|%aI|%s",
"-n",
"20",
"--",
relative_str,
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// No commits yet is not an error - just return empty history
if stderr.contains("does not have any commits yet") {
return Ok(Vec::new());
}
return Err(format!("git log failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let commits = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
// Format: hash|short_hash|author|date|message
// Use splitn(5) so message (last) can contain '|'
let parts: Vec<&str> = line.splitn(5, '|').collect();
if parts.len() != 5 {
return None;
}
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
Some(GitCommit {
hash: parts[0].to_string(),
short_hash: parts[1].to_string(),
author: parts[2].to_string(),
date,
message: parts[4].to_string(),
})
})
.collect();
Ok(commits)
}
/// Get git diff for a specific file.
pub fn get_file_diff(vault_path: &str, file_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
// First try tracked file diff
let output = Command::new("git")
.args(["diff", "--", relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
// If no diff (maybe staged or untracked), try diff --cached
if stdout.is_empty() {
let cached = Command::new("git")
.args(["diff", "--cached", "--", relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff --cached: {}", e))?;
let cached_stdout = String::from_utf8_lossy(&cached.stdout).to_string();
if !cached_stdout.is_empty() {
return Ok(cached_stdout);
}
// Try showing untracked file as all-new
let status = Command::new("git")
.args(["status", "--porcelain", "--", relative_str])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {}", e))?;
let status_out = String::from_utf8_lossy(&status.stdout);
if status_out.starts_with("??") {
// Untracked file: show entire content as added
let content =
std::fs::read_to_string(file).map_err(|e| format!("Failed to read file: {}", e))?;
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
return Ok(format!(
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
relative_str,
lines.len(),
lines.join("\n")
));
}
}
Ok(stdout)
}
/// Get git diff for a specific file at a given commit (compared to its parent).
pub fn get_file_diff_at_commit(
vault_path: &str,
file_path: &str,
commit_hash: &str,
) -> Result<String, String> {
let vault = Path::new(vault_path);
let file = Path::new(file_path);
let relative = file
.strip_prefix(vault)
.map_err(|_| format!("File {} is not inside vault {}", file_path, vault_path))?;
let relative_str = relative
.to_str()
.ok_or_else(|| "Invalid UTF-8 in path".to_string())?;
// Show diff between commit^ and commit for this file
let output = Command::new("git")
.args([
"diff",
&format!("{}^", commit_hash),
commit_hash,
"--",
relative_str,
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git diff: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
// If diff is empty, it might be the initial commit (no parent).
// Fall back to showing the full file content as added.
if stdout.is_empty() {
let show = Command::new("git")
.args(["show", &format!("{}:{}", commit_hash, relative_str)])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git show: {}", e))?;
if show.status.success() {
let content = String::from_utf8_lossy(&show.stdout);
let lines: Vec<String> = content.lines().map(|l| format!("+{}", l)).collect();
return Ok(format!(
"diff --git a/{0} b/{0}\nnew file\n--- /dev/null\n+++ b/{0}\n@@ -0,0 +1,{1} @@\n{2}",
relative_str,
lines.len(),
lines.join("\n")
));
}
}
Ok(stdout)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_get_file_history_with_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("test.md");
fs::write(&file, "# Initial\n").unwrap();
Command::new("git")
.args(["add", "test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# Updated\n\nNew content.").unwrap();
Command::new("git")
.args(["add", "test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Update test"])
.current_dir(vault)
.output()
.unwrap();
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].message, "Update test");
assert_eq!(history[1].message, "Initial commit");
assert_eq!(history[0].author, "Test User");
assert!(!history[0].hash.is_empty());
assert!(!history[0].short_hash.is_empty());
}
#[test]
fn test_get_file_history_no_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("new.md");
fs::write(&file, "# New\n").unwrap();
let history = get_file_history(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert!(history.is_empty());
}
#[test]
fn test_get_file_diff() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("diff-test.md");
fs::write(&file, "# Test\n\nOriginal content.").unwrap();
Command::new("git")
.args(["add", "diff-test.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add diff-test"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# Test\n\nModified content.").unwrap();
let diff = get_file_diff(vault.to_str().unwrap(), file.to_str().unwrap()).unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("-Original content."));
assert!(diff.contains("+Modified content."));
}
#[test]
fn test_get_file_diff_at_commit() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("diff-at-commit.md");
fs::write(&file, "# First\n\nOriginal content.").unwrap();
Command::new("git")
.args(["add", "diff-at-commit.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "First commit"])
.current_dir(vault)
.output()
.unwrap();
fs::write(&file, "# First\n\nModified content.").unwrap();
Command::new("git")
.args(["add", "diff-at-commit.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Second commit"])
.current_dir(vault)
.output()
.unwrap();
// Get hash of second commit
let log = Command::new("git")
.args(["log", "--format=%H", "-1"])
.current_dir(vault)
.output()
.unwrap();
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
.unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("-Original content."));
assert!(diff.contains("+Modified content."));
}
#[test]
fn test_get_file_diff_at_initial_commit() {
let dir = setup_git_repo();
let vault = dir.path();
let file = vault.join("initial.md");
fs::write(&file, "# Initial\n\nHello world.").unwrap();
Command::new("git")
.args(["add", "initial.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(vault)
.output()
.unwrap();
let log = Command::new("git")
.args(["log", "--format=%H", "-1"])
.current_dir(vault)
.output()
.unwrap();
let hash = String::from_utf8_lossy(&log.stdout).trim().to_string();
let diff = get_file_diff_at_commit(vault.to_str().unwrap(), file.to_str().unwrap(), &hash)
.unwrap();
assert!(!diff.is_empty());
assert!(diff.contains("+# Initial"));
assert!(diff.contains("+Hello world."));
}
}

352
src-tauri/src/git/mod.rs Normal file
View File

@@ -0,0 +1,352 @@
mod commit;
mod conflict;
mod history;
mod pulse;
mod remote;
mod status;
use std::path::Path;
use std::process::Command;
pub use commit::git_commit;
pub use conflict::{
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
is_merge_in_progress, is_rebase_in_progress,
};
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
pub use remote::{git_pull, git_push, has_remote, GitPullResult};
pub use status::{get_modified_files, ModifiedFile};
use serde::Serialize;
#[derive(Debug, Serialize, Clone)]
pub struct GitCommit {
pub hash: String,
#[serde(rename = "shortHash")]
pub short_hash: String,
pub message: String,
pub author: String,
pub date: i64,
}
/// Initialize a new git repository, stage all files, and create an initial commit.
pub fn init_repo(path: &str) -> Result<(), String> {
let dir = Path::new(path);
run_git(dir, &["init"])?;
ensure_author_config(dir)?;
// Write .gitignore before the first commit so machine-specific and
// macOS metadata files are never tracked and don't cause conflicts.
let gitignore_path = dir.join(".gitignore");
if !gitignore_path.exists() {
std::fs::write(
&gitignore_path,
"# Laputa app files (machine-specific, never commit)\n\
.laputa-cache.json\n\
.laputa/settings.json\n\
\n\
# macOS\n\
.DS_Store\n\
.AppleDouble\n\
.LSOverride\n\
\n\
# Thumbnails\n\
._*\n\
\n\
# Editors\n\
.vscode/\n\
.idea/\n\
*.swp\n\
*.swo\n",
)
.map_err(|e| format!("Failed to write .gitignore: {}", e))?;
}
run_git(dir, &["add", "."])?;
run_git(dir, &["commit", "-m", "Initial vault setup"])?;
Ok(())
}
/// Run a git command in the given directory, returning an error on failure.
fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to run git {}: {}", args[0], e))?;
if output.status.success() {
return Ok(());
}
Err(format!(
"git {} failed: {}",
args[0],
String::from_utf8_lossy(&output.stderr)
))
}
/// Set local user.name and user.email if not already configured.
fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
let check = Command::new("git")
.args(["config", key])
.current_dir(dir)
.output()
.map_err(|e| format!("Failed to check git config: {}", e))?;
let value = String::from_utf8_lossy(&check.stdout);
if !check.status.success() || value.trim().is_empty() {
run_git(dir, &["config", key, fallback])?;
}
}
Ok(())
}
/// Extract "owner/repo" from a GitHub remote URL.
/// Supports HTTPS (https://github.com/owner/repo.git) and
/// SSH (git@github.com:owner/repo.git) formats.
fn parse_github_repo_path(url: &str) -> Option<String> {
let trimmed = url.trim();
// SSH format: git@github.com:owner/repo.git
if let Some(rest) = trimmed.strip_prefix("git@github.com:") {
let path = rest.strip_suffix(".git").unwrap_or(rest);
if path.contains('/') {
return Some(path.to_string());
}
}
// HTTPS format: https://github.com/owner/repo.git
// Also handle token-embedded URLs: https://token@github.com/owner/repo.git
if trimmed.contains("github.com/") {
let after = trimmed.split("github.com/").nth(1)?;
let path = after.strip_suffix(".git").unwrap_or(after);
if path.contains('/') {
return Some(path.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
pub(crate) fn setup_git_repo() -> TempDir {
let dir = TempDir::new().unwrap();
let path = dir.path();
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(path)
.output()
.unwrap();
dir
}
/// Set up a bare "remote" and a clone that acts as the working vault.
pub(crate) fn setup_remote_pair() -> (TempDir, TempDir, TempDir) {
let bare_dir = TempDir::new().unwrap();
let bare = bare_dir.path();
Command::new("git")
.args(["init", "--bare"])
.current_dir(bare)
.output()
.unwrap();
let clone_a_dir = TempDir::new().unwrap();
Command::new("git")
.args(["clone", bare.to_str().unwrap(), "."])
.current_dir(clone_a_dir.path())
.output()
.unwrap();
for cmd in &[
&["config", "user.email", "a@test.com"][..],
&["config", "user.name", "User A"][..],
] {
Command::new("git")
.args(*cmd)
.current_dir(clone_a_dir.path())
.output()
.unwrap();
}
let clone_b_dir = TempDir::new().unwrap();
Command::new("git")
.args(["clone", bare.to_str().unwrap(), "."])
.current_dir(clone_b_dir.path())
.output()
.unwrap();
for cmd in &[
&["config", "user.email", "b@test.com"][..],
&["config", "user.name", "User B"][..],
] {
Command::new("git")
.args(*cmd)
.current_dir(clone_b_dir.path())
.output()
.unwrap();
}
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_init_repo_creates_git_directory() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
assert!(vault.join(".git").exists());
}
#[test]
fn test_init_repo_creates_initial_commit() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let log = Command::new("git")
.args(["log", "--oneline"])
.current_dir(&vault)
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Initial vault setup"));
}
#[test]
fn test_init_repo_stages_all_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(vault.join("sub")).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
fs::write(vault.join("sub/nested.md"), "# Nested\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let status = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&vault)
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
"All files should be committed"
);
}
#[test]
fn test_init_repo_creates_gitignore_with_ds_store() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let gitignore = vault.join(".gitignore");
assert!(
gitignore.exists(),
".gitignore should be created by init_repo"
);
let content = fs::read_to_string(&gitignore).unwrap();
assert!(
content.contains(".DS_Store"),
".gitignore should exclude .DS_Store"
);
assert!(
content.contains(".laputa-cache.json"),
".gitignore should exclude .laputa-cache.json"
);
assert!(
content.contains(".laputa/settings.json"),
".gitignore should exclude settings.json"
);
}
#[test]
fn test_init_repo_does_not_overwrite_existing_gitignore() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("new-vault");
fs::create_dir_all(&vault).unwrap();
fs::write(vault.join("note.md"), "# Test\n").unwrap();
fs::write(vault.join(".gitignore"), "custom-rule\n").unwrap();
init_repo(vault.to_str().unwrap()).unwrap();
let content = fs::read_to_string(vault.join(".gitignore")).unwrap();
assert_eq!(
content, "custom-rule\n",
"existing .gitignore should not be overwritten"
);
}
#[test]
fn test_parse_github_repo_path_https() {
assert_eq!(
parse_github_repo_path("https://github.com/owner/repo.git"),
Some("owner/repo".to_string())
);
assert_eq!(
parse_github_repo_path("https://github.com/owner/repo"),
Some("owner/repo".to_string())
);
}
#[test]
fn test_parse_github_repo_path_ssh() {
assert_eq!(
parse_github_repo_path("git@github.com:owner/repo.git"),
Some("owner/repo".to_string())
);
assert_eq!(
parse_github_repo_path("git@github.com:owner/repo"),
Some("owner/repo".to_string())
);
}
#[test]
fn test_parse_github_repo_path_token_embedded() {
assert_eq!(
parse_github_repo_path("https://gho_abc123@github.com/owner/repo.git"),
Some("owner/repo".to_string())
);
}
#[test]
fn test_parse_github_repo_path_non_github() {
assert_eq!(
parse_github_repo_path("https://gitlab.com/owner/repo.git"),
None
);
}
}

483
src-tauri/src/git/pulse.rs Normal file
View File

@@ -0,0 +1,483 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use super::parse_github_repo_path;
#[derive(Debug, Serialize, Clone)]
pub struct PulseFile {
pub path: String,
pub status: String,
pub title: String,
}
#[derive(Debug, Serialize, Clone)]
pub struct PulseCommit {
pub hash: String,
#[serde(rename = "shortHash")]
pub short_hash: String,
pub message: String,
pub date: i64,
#[serde(rename = "githubUrl")]
pub github_url: Option<String>,
pub files: Vec<PulseFile>,
pub added: usize,
pub modified: usize,
pub deleted: usize,
}
#[derive(Debug, Serialize, Clone)]
pub struct LastCommitInfo {
#[serde(rename = "shortHash")]
pub short_hash: String,
#[serde(rename = "commitUrl")]
pub commit_url: Option<String>,
}
fn title_from_path(path: &str) -> String {
path.rsplit('/')
.next()
.unwrap_or(path)
.strip_suffix(".md")
.unwrap_or(path)
.replace('-', " ")
}
fn parse_file_status(code: &str) -> &str {
match code {
"A" => "added",
"M" => "modified",
"D" => "deleted",
_ => "modified",
}
}
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
pub fn get_vault_pulse(vault_path: &str, limit: usize, skip: usize) -> Result<Vec<PulseCommit>, String> {
let vault = Path::new(vault_path);
if !vault.join(".git").exists() {
return Err("Not a git repository".to_string());
}
let limit_str = limit.to_string();
let skip_str = skip.to_string();
let output = Command::new("git")
.args([
"log",
"--name-status",
"--pretty=format:%H|%h|%s|%aI",
"--diff-filter=ADM",
"-n",
&limit_str,
"--skip",
&skip_str,
"--",
"*.md",
])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not have any commits yet") {
return Ok(Vec::new());
}
return Err(format!("git log failed: {}", stderr));
}
let github_base = get_github_base_url(vault_path);
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(parse_pulse_output(&stdout, &github_base))
}
fn get_github_base_url(vault_path: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = parse_github_repo_path(&url)?;
Some(format!("https://github.com/{}", repo_path))
}
fn parse_pulse_output(stdout: &str, github_base: &Option<String>) -> Vec<PulseCommit> {
let mut commits: Vec<PulseCommit> = Vec::new();
let mut current: Option<PulseCommit> = None;
for line in stdout.lines() {
if line.is_empty() {
continue;
}
if line.contains('|')
&& !line.starts_with(|c: char| {
c.is_ascii_uppercase() && line.len() > 1 && line.as_bytes().get(1) == Some(&b'\t')
})
{
// Commit header line: hash|short_hash|message|date
if let Some(commit) = current.take() {
commits.push(commit);
}
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
let hash = parts[0];
let date = chrono::DateTime::parse_from_rfc3339(parts[3])
.map(|dt| dt.timestamp())
.unwrap_or(0);
let github_url = github_base
.as_ref()
.map(|base| format!("{}/commit/{}", base, hash));
current = Some(PulseCommit {
hash: hash.to_string(),
short_hash: parts[1].to_string(),
message: parts[2].to_string(),
date,
github_url,
files: Vec::new(),
added: 0,
modified: 0,
deleted: 0,
});
}
} else if let Some(ref mut commit) = current {
// File status line: A\tpath or M\tpath
let file_parts: Vec<&str> = line.splitn(2, '\t').collect();
if file_parts.len() == 2 {
let status = parse_file_status(file_parts[0].trim());
let path = file_parts[1].trim();
match status {
"added" => commit.added += 1,
"deleted" => commit.deleted += 1,
_ => commit.modified += 1,
}
commit.files.push(PulseFile {
path: path.to_string(),
status: status.to_string(),
title: title_from_path(path),
});
}
}
}
if let Some(commit) = current {
commits.push(commit);
}
commits
}
/// Get the last commit's short hash and a GitHub URL (if remote is GitHub).
pub fn get_last_commit_info(vault_path: &str) -> Result<Option<LastCommitInfo>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["log", "-1", "--format=%H|%h"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not have any commits yet") {
return Ok(None);
}
return Err(format!("git log failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.trim();
if line.is_empty() {
return Ok(None);
}
let parts: Vec<&str> = line.splitn(2, '|').collect();
if parts.len() != 2 {
return Ok(None);
}
let full_hash = parts[0];
let short_hash = parts[1].to_string();
let commit_url = get_github_commit_url(vault_path, full_hash);
Ok(Some(LastCommitInfo {
short_hash,
commit_url,
}))
}
/// Try to build a GitHub commit URL from the origin remote URL.
fn get_github_commit_url(vault_path: &str, full_hash: &str) -> Option<String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(vault)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let repo_path = parse_github_repo_path(&url)?;
Some(format!(
"https://github.com/{}/commit/{}",
repo_path, full_hash
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_commit;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn test_get_vault_pulse_with_commits() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
fs::write(vault.join("project.md"), "# Project\n").unwrap();
git_commit(vp, "Add project").unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse.len(), 2);
assert_eq!(pulse[0].message, "Add project");
assert_eq!(pulse[1].message, "Add note");
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "project.md");
assert_eq!(pulse[0].files[0].status, "added");
assert_eq!(pulse[0].added, 1);
assert_eq!(pulse[0].modified, 0);
assert!(!pulse[0].short_hash.is_empty());
}
#[test]
fn test_get_vault_pulse_no_git() {
let dir = TempDir::new().unwrap();
let vp = dir.path().to_str().unwrap();
let result = get_vault_pulse(vp, 30, 0);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Not a git repository"));
}
#[test]
fn test_get_vault_pulse_empty_repo() {
let dir = setup_git_repo();
let vp = dir.path().to_str().unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse.is_empty());
}
#[test]
fn test_get_vault_pulse_only_md_files() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
fs::write(vault.join("config.json"), "{}").unwrap();
git_commit(vp, "Add files").unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse.len(), 1);
assert_eq!(pulse[0].files.len(), 1);
assert_eq!(pulse[0].files[0].path, "note.md");
}
#[test]
fn test_get_vault_pulse_respects_limit() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
for i in 0..5 {
fs::write(
vault.join(format!("note{}.md", i)),
format!("# Note {}\n", i),
)
.unwrap();
git_commit(vp, &format!("Add note {}", i)).unwrap();
}
let pulse = get_vault_pulse(vp, 3, 0).unwrap();
assert_eq!(pulse.len(), 3);
}
#[test]
fn test_get_vault_pulse_modified_and_deleted() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
fs::write(vault.join("note.md"), "# Updated\n").unwrap();
git_commit(vp, "Update note").unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert_eq!(pulse[0].message, "Update note");
assert_eq!(pulse[0].files[0].status, "modified");
assert_eq!(pulse[0].modified, 1);
}
#[test]
fn test_get_vault_pulse_github_url() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(vault)
.output()
.unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse[0].github_url.is_some());
let url = pulse[0].github_url.as_ref().unwrap();
assert!(url.starts_with("https://github.com/owner/repo/commit/"));
}
#[test]
fn test_get_vault_pulse_no_github_url_without_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "Add note").unwrap();
let pulse = get_vault_pulse(vp, 30, 0).unwrap();
assert!(pulse[0].github_url.is_none());
}
#[test]
fn test_title_from_path() {
assert_eq!(title_from_path("note/my-project.md"), "my project");
assert_eq!(title_from_path("simple.md"), "simple");
assert_eq!(title_from_path("deep/nested/file.md"), "file");
}
#[test]
fn test_parse_pulse_output_basic() {
let stdout =
"abc123|abc123d|Add notes|2026-03-05T10:00:00+01:00\nA\tnote.md\nM\tproject.md\n";
let commits = parse_pulse_output(stdout, &None);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].message, "Add notes");
assert_eq!(commits[0].files.len(), 2);
assert_eq!(commits[0].files[0].status, "added");
assert_eq!(commits[0].files[1].status, "modified");
assert_eq!(commits[0].added, 1);
assert_eq!(commits[0].modified, 1);
assert!(commits[0].github_url.is_none());
}
#[test]
fn test_parse_pulse_output_with_github() {
let stdout = "abc123|abc123d|Msg|2026-03-05T10:00:00+01:00\nA\tnote.md\n";
let base = Some("https://github.com/o/r".to_string());
let commits = parse_pulse_output(stdout, &base);
assert_eq!(
commits[0].github_url.as_deref(),
Some("https://github.com/o/r/commit/abc123")
);
}
#[test]
fn test_parse_pulse_output_multiple_commits() {
let stdout = "aaa|aaa1234|First|2026-03-05T10:00:00+01:00\nA\ta.md\n\nbbb|bbb1234|Second|2026-03-04T10:00:00+01:00\nM\tb.md\nD\tc.md\n";
let commits = parse_pulse_output(stdout, &None);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].message, "First");
assert_eq!(commits[1].message, "Second");
assert_eq!(commits[1].files.len(), 2);
assert_eq!(commits[1].deleted, 1);
}
#[test]
fn test_get_last_commit_info_with_commit() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
let info = get_last_commit_info(vp).unwrap();
assert!(info.is_some());
let info = info.unwrap();
assert_eq!(info.short_hash.len(), 7);
assert!(info.commit_url.is_none());
}
#[test]
fn test_get_last_commit_info_no_commits() {
let dir = setup_git_repo();
let vp = dir.path().to_str().unwrap();
let info = get_last_commit_info(vp).unwrap();
assert!(info.is_none());
}
#[test]
fn test_get_last_commit_info_with_github_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/lucaong/laputa-vault.git",
])
.current_dir(vault)
.output()
.unwrap();
let info = get_last_commit_info(vp).unwrap().unwrap();
assert!(info.commit_url.is_some());
let url = info.commit_url.unwrap();
assert!(url.starts_with("https://github.com/lucaong/laputa-vault/commit/"));
}
}

246
src-tauri/src/git/remote.rs Normal file
View File

@@ -0,0 +1,246 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use super::conflict::get_conflict_files;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitPullResult {
pub status: String, // "up_to_date" | "updated" | "conflict" | "no_remote" | "error"
pub message: String,
#[serde(rename = "updatedFiles")]
pub updated_files: Vec<String>,
#[serde(rename = "conflictFiles")]
pub conflict_files: Vec<String>,
}
/// Check whether the vault repo has at least one remote configured.
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["remote"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git remote: {}", e))?;
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
}
/// Pull latest changes from remote. Uses --no-rebase to merge.
/// Returns a structured result with status and affected files.
pub fn git_pull(vault_path: &str) -> Result<GitPullResult, String> {
let vault = Path::new(vault_path);
if !has_remote(vault_path)? {
return Ok(GitPullResult {
status: "no_remote".to_string(),
message: "No remote configured".to_string(),
updated_files: vec![],
conflict_files: vec![],
});
}
let output = Command::new("git")
.args(["pull", "--no-rebase"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git pull: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if output.status.success() {
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
return Ok(GitPullResult {
status: "up_to_date".to_string(),
message: "Already up to date".to_string(),
updated_files: vec![],
conflict_files: vec![],
});
}
let updated = parse_updated_files(&stdout);
return Ok(GitPullResult {
status: "updated".to_string(),
message: format!("{} file(s) updated", updated.len()),
updated_files: updated,
conflict_files: vec![],
});
}
// Check for merge conflicts
let conflicts = get_conflict_files(vault_path).unwrap_or_default();
if !conflicts.is_empty() {
return Ok(GitPullResult {
status: "conflict".to_string(),
message: format!("Merge conflict in {} file(s)", conflicts.len()),
updated_files: vec![],
conflict_files: conflicts,
});
}
// Network error or other failure — report as error
let detail = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else {
stderr.trim().to_string()
};
Ok(GitPullResult {
status: "error".to_string(),
message: detail,
updated_files: vec![],
conflict_files: vec![],
})
}
/// Parse `git pull` output to extract updated file paths.
fn parse_updated_files(stdout: &str) -> Vec<String> {
stdout
.lines()
.filter_map(|line| {
let trimmed = line.trim();
// Lines like " path/to/file.md | 5 ++-" in diffstat
if trimmed.contains('|') {
let path = trimmed.split('|').next()?.trim();
if !path.is_empty() {
return Some(path.to_string());
}
}
None
})
.collect()
}
/// Push to remote.
pub fn git_push(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["push"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git push: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git push failed: {}", stderr));
}
// git push often writes to stderr even on success
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(format!("{}{}", stdout, stderr))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_commit;
use crate::git::tests::{setup_git_repo, setup_remote_pair};
use std::fs;
use std::process::Command;
#[test]
fn test_has_remote_returns_false_for_local_repo() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
assert!(!has_remote(vp).unwrap());
}
#[test]
fn test_has_remote_returns_true_when_remote_exists() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
Command::new("git")
.args(["remote", "add", "origin", "https://example.com/repo.git"])
.current_dir(vault)
.output()
.unwrap();
assert!(has_remote(vp).unwrap());
}
#[test]
fn test_git_pull_no_remote_returns_no_remote() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Note\n").unwrap();
git_commit(vp, "initial").unwrap();
let result = git_pull(vp).unwrap();
assert_eq!(result.status, "no_remote");
assert!(result.updated_files.is_empty());
assert!(result.conflict_files.is_empty());
}
#[test]
fn test_git_pull_up_to_date() {
let (_bare, clone_a, _clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(vp_a, "initial").unwrap();
git_push(vp_a).unwrap();
let result = git_pull(vp_a).unwrap();
assert_eq!(result.status, "up_to_date");
}
#[test]
fn test_git_pull_updated_files() {
let (_bare, clone_a, clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
let vp_b = clone_b.path().to_str().unwrap();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(vp_a, "initial").unwrap();
git_push(vp_a).unwrap();
git_pull(vp_b).unwrap();
fs::write(clone_a.path().join("note.md"), "# Updated Note\n").unwrap();
git_commit(vp_a, "update note").unwrap();
git_push(vp_a).unwrap();
let result = git_pull(vp_b).unwrap();
assert_eq!(result.status, "updated");
assert!(result.conflict_files.is_empty());
}
#[test]
fn test_parse_updated_files_diffstat() {
let stdout =
" Fast-forward\n note.md | 2 +-\n project/plan.md | 4 ++--\n 2 files changed\n";
let files = parse_updated_files(stdout);
assert_eq!(files, vec!["note.md", "project/plan.md"]);
}
#[test]
fn test_parse_updated_files_empty() {
let stdout = "Already up to date.\n";
let files = parse_updated_files(stdout);
assert!(files.is_empty());
}
#[test]
fn test_git_pull_result_serialization() {
let result = GitPullResult {
status: "updated".to_string(),
message: "2 file(s) updated".to_string(),
updated_files: vec!["note.md".to_string()],
conflict_files: vec![],
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"updatedFiles\""));
assert!(json.contains("\"conflictFiles\""));
let parsed: GitPullResult = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.status, "updated");
assert_eq!(parsed.updated_files.len(), 1);
}
}

175
src-tauri/src/git/status.rs Normal file
View File

@@ -0,0 +1,175 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Serialize, Clone)]
pub struct ModifiedFile {
pub path: String,
#[serde(rename = "relativePath")]
pub relative_path: String,
pub status: String,
}
/// Get list of modified/added/deleted files in the vault (uncommitted changes).
pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git status failed: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
if line.len() < 4 {
return None;
}
let status_code = &line[..2];
let path = line[3..].trim().to_string();
// Only include markdown files
if !path.ends_with(".md") {
return None;
}
let status = match status_code.trim() {
"M" | "MM" | "AM" => "modified",
"A" => "added",
"D" => "deleted",
"??" => "untracked",
"R" | "RM" => "renamed",
_ => "modified",
};
let full_path = vault.join(&path).to_string_lossy().to_string();
Some(ModifiedFile {
path: full_path,
relative_path: path,
status: status.to_string(),
})
})
.collect();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::git_commit;
use crate::git::tests::setup_git_repo;
use std::fs;
use std::process::Command;
#[test]
fn test_get_modified_files() {
let dir = setup_git_repo();
let vault = dir.path();
// Create and commit a file
fs::write(vault.join("note.md"), "# Note\n").unwrap();
Command::new("git")
.args(["add", "note.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Add note"])
.current_dir(vault)
.output()
.unwrap();
// Modify it
fs::write(vault.join("note.md"), "# Note\n\nUpdated.").unwrap();
// Add an untracked file
fs::write(vault.join("new.md"), "# New\n").unwrap();
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
assert!(modified.len() >= 2);
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
assert!(statuses.contains(&"modified"));
assert!(statuses.contains(&"untracked"));
}
#[test]
fn test_get_modified_files_untracked_in_subdirectory() {
let dir = setup_git_repo();
let vault = dir.path();
// Create initial commit so git is initialized
fs::write(vault.join("init.md"), "# Init\n").unwrap();
Command::new("git")
.args(["add", "init.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(vault)
.output()
.unwrap();
// Create a new untracked file in a subdirectory (simulates new note creation)
fs::create_dir_all(vault.join("note")).unwrap();
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
assert_eq!(modified.len(), 1);
assert_eq!(modified[0].status, "untracked");
assert_eq!(modified[0].relative_path, "note/brand-new.md");
assert!(
modified[0].path.ends_with("/note/brand-new.md"),
"Full path should end with relative path: {}",
modified[0].path
);
}
#[test]
fn test_commit_flow_modified_files_then_commit_clears() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
// Create and commit initial file
fs::write(vault.join("flow.md"), "# Original\n").unwrap();
git_commit(vp, "initial").unwrap();
// Modify the file on disk
fs::write(vault.join("flow.md"), "# Modified\n").unwrap();
// get_modified_files should detect the change
let modified = get_modified_files(vp).unwrap();
assert!(
modified.iter().any(|f| f.relative_path == "flow.md"),
"Modified file should be detected after write"
);
// Commit the change
let result = git_commit(vp, "update flow").unwrap();
assert!(
result.contains("1 file changed") || result.contains("flow.md"),
"Commit output should reference the changed file: {}",
result
);
// After commit, get_modified_files should return empty
let after = get_modified_files(vp).unwrap();
assert!(
after.is_empty(),
"No modified files should remain after commit, found: {:?}",
after
);
}
}

View File

@@ -1,886 +0,0 @@
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
/// GitHub App client ID for OAuth device flow.
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GithubRepo {
pub name: String,
pub full_name: String,
pub description: Option<String>,
pub private: bool,
pub clone_url: String,
pub html_url: String,
pub updated_at: Option<String>,
}
/// Lists the authenticated user's GitHub repositories.
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
github_list_repos_with_base(token, "https://api.github.com").await
}
async fn github_list_repos_with_base(
token: &str,
api_base: &str,
) -> Result<Vec<GithubRepo>, String> {
let client = reqwest::Client::new();
let mut all_repos: Vec<GithubRepo> = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"{}/user/repos?per_page=100&sort=updated&page={}",
api_base, page
);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
let repos: Vec<GithubRepo> = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
let count = repos.len();
all_repos.extend(repos);
if count < 100 {
break;
}
page += 1;
if page > 10 {
break; // safety limit: 1000 repos max
}
}
Ok(all_repos)
}
/// Creates a new GitHub repository for the authenticated user.
pub async fn github_create_repo(
token: &str,
name: &str,
private: bool,
) -> Result<GithubRepo, String> {
github_create_repo_with_base(token, name, private, "https://api.github.com").await
}
async fn github_create_repo_with_base(
token: &str,
name: &str,
private: bool,
api_base: &str,
) -> Result<GithubRepo, String> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"name": name,
"private": private,
"auto_init": true,
"description": "Laputa vault"
});
let response = client
.post(format!("{}/user/repos", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&body)
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 422 && body.contains("name already exists") {
return Err("Repository name already exists on your account".to_string());
}
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GithubRepo>()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
}
// --- OAuth Device Flow ---
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct DeviceFlowStart {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct DeviceFlowPollResult {
pub status: String,
pub access_token: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubUser {
pub login: String,
pub name: Option<String>,
pub avatar_url: String,
}
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
github_device_flow_start_with_base("https://github.com").await
}
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
let client = reqwest::Client::new();
let response = client
.post(format!("{}/login/device/code", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
.send()
.await
.map_err(|e| format!("Device flow request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 404 {
return Err(
"GitHub device flow not available. Ensure a GitHub App is registered with \
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
.to_string(),
);
}
return Err(format!("Device flow start failed ({}): {}", status, body));
}
response
.json::<DeviceFlowStart>()
.await
.map_err(|e| format!("Failed to parse device flow response: {}", e))
}
/// Polls GitHub for the device flow authorization result.
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
github_device_flow_poll_with_base(device_code, "https://github.com").await
}
async fn github_device_flow_poll_with_base(
device_code: &str,
base_url: &str,
) -> Result<DeviceFlowPollResult, String> {
let client = reqwest::Client::new();
let response = client
.post(format!("{}/login/oauth/access_token", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[
("client_id", GITHUB_CLIENT_ID),
("device_code", device_code),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.map_err(|e| format!("Device flow poll failed: {}", e))?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("Device flow poll HTTP error: {}", body));
}
#[derive(Deserialize)]
struct RawResponse {
access_token: Option<String>,
error: Option<String>,
}
let raw: RawResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
if let Some(token) = raw.access_token {
Ok(DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some(token),
error: None,
})
} else {
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
let status = match error.as_str() {
"authorization_pending" | "slow_down" => "pending",
"expired_token" => "expired",
_ => "error",
};
Ok(DeviceFlowPollResult {
status: status.to_string(),
access_token: None,
error: Some(error),
})
}
}
/// Gets the authenticated GitHub user's profile.
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
github_get_user_with_base(token, "https://api.github.com").await
}
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/user", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub user request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GitHubUser>()
.await
.map_err(|e| format!("Failed to parse user response: {}", e))
}
/// Clones a GitHub repo to a local path using HTTPS + token auth.
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
let dest = Path::new(local_path);
if dest.exists()
&& dest
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(false)
{
return Err(format!(
"Destination '{}' already exists and is not empty",
local_path
));
}
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
let auth_url = inject_token_into_url(url, token)?;
let output = Command::new("git")
.args(["clone", "--progress", &auth_url, local_path])
.output()
.map_err(|e| format!("Failed to run git clone: {}", e))?;
if !output.status.success() {
// Clean up partial clone on failure
if dest.exists() {
let _ = std::fs::remove_dir_all(dest);
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git clone failed: {}", stderr));
}
// Configure the remote to use token auth for future pushes
configure_remote_auth(local_path, url, token)?;
Ok(format!("Cloned to {}", local_path))
}
/// Injects an OAuth token into an HTTPS GitHub URL.
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
if let Some(rest) = url.strip_prefix("https://github.com/") {
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
} else if let Some(rest) = url.strip_prefix("https://") {
// Handle URLs that already have a host
Ok(format!("https://oauth2:{}@{}", token, rest))
} else {
Err(format!(
"Unsupported URL format: {}. Use an HTTPS URL.",
url
))
}
}
/// Sets up the git remote to use token-based HTTPS auth.
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
let auth_url = inject_token_into_url(original_url, token)?;
let vault = Path::new(local_path);
let output = Command::new("git")
.args(["remote", "set-url", "origin", &auth_url])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to configure remote: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to set remote URL: {}", stderr));
}
// Also configure git user if not set
let _ = Command::new("git")
.args(["config", "user.email", "laputa@app.local"])
.current_dir(vault)
.output();
let _ = Command::new("git")
.args(["config", "user.name", "Laputa App"])
.current_dir(vault)
.output();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
// ── Mock helpers ─────────────────────────────────────────────────────────
async fn mock_json(
method: &str,
path: &str,
status: usize,
body: &str,
) -> (mockito::ServerGuard, mockito::Mock) {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock(method, path)
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
(server, mock)
}
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
.await
.unwrap();
mock.assert_async().await;
result
}
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
let (server, mock) = mock_json("GET", "/user", status, body).await;
let result = github_get_user_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
fn clone_err_contains(url: &str, expected: &str) {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("dest");
let result = clone_repo(url, "token", dest.to_str().unwrap());
assert!(result.unwrap_err().contains(expected));
}
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
let (server, mock) = mock_json(
"GET",
"/user/repos?per_page=100&sort=updated&page=1",
status,
body,
)
.await;
let result = github_list_repos_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
let result = github_device_flow_start_with_base(&server.url()).await;
mock.assert_async().await;
result
}
// ── Sync/pure logic tests ────────────────────────────────────────────────
#[test]
fn test_inject_token_basic_github_url() {
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
assert_eq!(
result.unwrap(),
"https://oauth2:gho_abc123@github.com/user/repo.git"
);
}
#[test]
fn test_inject_token_generic_https_url() {
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
assert_eq!(
result.unwrap(),
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
);
}
#[test]
fn test_inject_token_ssh_url_rejected() {
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
assert!(err.contains("Unsupported URL format"));
}
#[test]
fn test_inject_token_http_url_rejected() {
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
}
#[test]
fn test_inject_token_github_without_dot_git() {
let result = inject_token_into_url("https://github.com/user/repo", "tok");
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
}
#[test]
fn test_clone_repo_nonempty_dest() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
let result = clone_repo(
"https://github.com/test/repo.git",
"token",
dir.path().to_str().unwrap(),
);
assert!(result.unwrap_err().contains("not empty"));
}
#[test]
fn test_clone_repo_ssh_url_rejected() {
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
}
#[test]
fn test_clone_repo_empty_dest_allowed() {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("empty-dir");
std::fs::create_dir(&dest).unwrap();
let result = clone_repo(
"https://github.com/nonexistent/repo.git",
"token",
dest.to_str().unwrap(),
);
// Should fail at git clone, not at directory check
assert!(result.unwrap_err().contains("git clone failed"));
}
#[test]
fn test_configure_remote_auth_on_git_repo() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path();
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/user/repo.git",
])
.current_dir(path)
.output()
.unwrap();
configure_remote_auth(
path.to_str().unwrap(),
"https://github.com/user/repo.git",
"gho_test123",
)
.unwrap();
let output = StdCommand::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(path)
.output()
.unwrap();
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
}
// ── Serialization roundtrip tests ────────────────────────────────────────
#[test]
fn test_github_repo_serialization_roundtrip() {
let repo = GithubRepo {
name: "test-repo".to_string(),
full_name: "user/test-repo".to_string(),
description: Some("A test repo".to_string()),
private: true,
clone_url: "https://github.com/user/test-repo.git".to_string(),
html_url: "https://github.com/user/test-repo".to_string(),
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
};
let json = serde_json::to_string(&repo).unwrap();
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
}
#[test]
fn test_github_repo_deserialization_null_fields() {
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
let repo: GithubRepo = serde_json::from_str(json).unwrap();
assert_eq!(repo.name, "r");
assert!(!repo.private);
assert!(repo.description.is_none());
assert!(repo.updated_at.is_none());
}
#[test]
fn test_device_flow_start_serialization_roundtrip() {
let start = DeviceFlowStart {
device_code: "dc_123".to_string(),
user_code: "ABCD-1234".to_string(),
verification_uri: "https://github.com/login/device".to_string(),
expires_in: 900,
interval: 5,
};
let json = serde_json::to_string(&start).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
start
);
}
#[test]
fn test_device_flow_poll_result_roundtrip() {
let complete = DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some("gho_abc123".to_string()),
error: None,
};
let json = serde_json::to_string(&complete).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
complete
);
let pending = DeviceFlowPollResult {
status: "pending".to_string(),
access_token: None,
error: Some("authorization_pending".to_string()),
};
let json = serde_json::to_string(&pending).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
pending
);
}
#[test]
fn test_github_user_serialization_roundtrip() {
let user = GitHubUser {
login: "lucaong".to_string(),
name: Some("Luca Ongaro".to_string()),
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
};
let json = serde_json::to_string(&user).unwrap();
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
}
#[test]
fn test_github_user_deserialization_null_name() {
let user: GitHubUser =
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
.unwrap();
assert_eq!(user.login, "bot");
assert!(user.name.is_none());
}
// ── HTTP mock: list repos ────────────────────────────────────────────────
#[tokio::test]
async fn test_github_list_repos_success() {
let repos = mock_list_repos(
200,
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
)
.await
.unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "my-repo");
}
#[tokio::test]
async fn test_github_list_repos_empty() {
let repos = mock_list_repos(200, "[]").await.unwrap();
assert!(repos.is_empty());
}
#[tokio::test]
async fn test_github_list_repos_auth_error() {
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error"));
}
#[tokio::test]
async fn test_github_list_repos_paginated() {
let mut server = mockito::Server::new_async().await;
let repos_page1: Vec<serde_json::Value> = (0..100)
.map(|i| {
serde_json::json!({
"name": format!("repo-{}", i),
"full_name": format!("user/repo-{}", i),
"description": null,
"private": false,
"clone_url": format!("https://github.com/user/repo-{}.git", i),
"html_url": format!("https://github.com/user/repo-{}", i),
"updated_at": null
})
})
.collect();
let mock1 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&repos_page1).unwrap())
.create_async()
.await;
let mock2 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
)
.create_async()
.await;
let repos = github_list_repos_with_base("token", &server.url())
.await
.unwrap();
mock1.assert_async().await;
mock2.assert_async().await;
assert_eq!(repos.len(), 101);
assert_eq!(repos[100].name, "extra-repo");
}
// ── HTTP mock: create repo ───────────────────────────────────────────────
#[tokio::test]
async fn test_github_create_repo_success() {
let repo = mock_create_repo(
201,
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
)
.await
.unwrap();
assert_eq!(repo.name, "new-repo");
assert!(repo.private);
}
#[tokio::test]
async fn test_github_create_repo_name_exists() {
let err = mock_create_repo(
422,
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
)
.await
.unwrap_err();
assert!(err.contains("Repository name already exists"));
}
#[tokio::test]
async fn test_github_create_repo_server_error() {
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 500"));
}
// ── HTTP mock: device flow start ─────────────────────────────────────────
#[tokio::test]
async fn test_github_device_flow_start_success() {
let start = mock_device_start(
200,
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
)
.await
.unwrap();
assert_eq!(
start,
DeviceFlowStart {
device_code: "dev_abc".to_string(),
user_code: "ABCD-1234".to_string(),
verification_uri: "https://github.com/login/device".to_string(),
expires_in: 900,
interval: 5,
}
);
}
#[tokio::test]
async fn test_github_device_flow_start_error() {
let err = mock_device_start(400, "bad request").await.unwrap_err();
assert!(err.contains("Device flow start failed"));
}
#[tokio::test]
async fn test_github_device_flow_start_404_gives_clear_message() {
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
.await
.unwrap_err();
assert!(err.contains("device flow not available"));
assert!(err.contains("Device authorization flow"));
}
// ── HTTP mock: device flow poll ──────────────────────────────────────────
#[tokio::test]
async fn test_github_device_flow_poll_complete() {
let poll =
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
.await;
assert_eq!(
poll,
DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some("gho_secret123".to_string()),
error: None,
}
);
}
#[tokio::test]
async fn test_github_device_flow_poll_pending() {
let poll = mock_poll(
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
)
.await;
assert_eq!(poll.status, "pending");
assert_eq!(poll.error, Some("authorization_pending".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_slow_down() {
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
assert_eq!(poll.status, "pending");
assert_eq!(poll.error, Some("slow_down".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_expired() {
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
assert_eq!(poll.status, "expired");
assert_eq!(poll.error, Some("expired_token".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_other_error() {
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
assert_eq!(poll.status, "error");
assert_eq!(poll.error, Some("access_denied".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_http_error() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/login/oauth/access_token")
.with_status(503)
.with_body("Service Unavailable")
.create_async()
.await;
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
.await
.unwrap_err();
mock.assert_async().await;
assert!(err.contains("Device flow poll HTTP error"));
}
#[tokio::test]
async fn test_github_device_flow_poll_unknown_error() {
let poll = mock_poll(r#"{}"#).await;
assert_eq!(poll.status, "error");
assert_eq!(poll.error, Some("unknown".to_string()));
}
// ── HTTP mock: get user ──────────────────────────────────────────────────
#[tokio::test]
async fn test_github_get_user_success() {
let user = mock_user(
200,
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
)
.await
.unwrap();
assert_eq!(
user,
GitHubUser {
login: "lucaong".to_string(),
name: Some("Luca Ongaro".to_string()),
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
}
);
}
#[tokio::test]
async fn test_github_get_user_unauthorized() {
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 401"));
}
#[tokio::test]
async fn test_github_get_user_null_name() {
let user = mock_user(
200,
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
)
.await
.unwrap();
assert_eq!(user.login, "bot-account");
assert!(user.name.is_none());
}
}

326
src-tauri/src/github/api.rs Normal file
View File

@@ -0,0 +1,326 @@
use super::{GitHubUser, GithubRepo};
/// Lists the authenticated user's GitHub repositories.
pub async fn github_list_repos(token: &str) -> Result<Vec<GithubRepo>, String> {
github_list_repos_with_base(token, "https://api.github.com").await
}
async fn github_list_repos_with_base(
token: &str,
api_base: &str,
) -> Result<Vec<GithubRepo>, String> {
let client = reqwest::Client::new();
let mut all_repos: Vec<GithubRepo> = Vec::new();
let mut page = 1u32;
loop {
let url = format!(
"{}/user/repos?per_page=100&sort=updated&page={}",
api_base, page
);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
let repos: Vec<GithubRepo> = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
let count = repos.len();
all_repos.extend(repos);
if count < 100 {
break;
}
page += 1;
if page > 10 {
break; // safety limit: 1000 repos max
}
}
Ok(all_repos)
}
/// Creates a new GitHub repository for the authenticated user.
pub async fn github_create_repo(
token: &str,
name: &str,
private: bool,
) -> Result<GithubRepo, String> {
github_create_repo_with_base(token, name, private, "https://api.github.com").await
}
async fn github_create_repo_with_base(
token: &str,
name: &str,
private: bool,
api_base: &str,
) -> Result<GithubRepo, String> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"name": name,
"private": private,
"auto_init": true,
"description": "Laputa vault"
});
let response = client
.post(format!("{}/user/repos", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&body)
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 422 && body.contains("name already exists") {
return Err("Repository name already exists on your account".to_string());
}
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GithubRepo>()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))
}
/// Gets the authenticated GitHub user's profile.
pub async fn github_get_user(token: &str) -> Result<GitHubUser, String> {
github_get_user_with_base(token, "https://api.github.com").await
}
async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHubUser, String> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/user", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.map_err(|e| format!("GitHub user request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("GitHub API error {}: {}", status, body));
}
response
.json::<GitHubUser>()
.await
.map_err(|e| format!("Failed to parse user response: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
async fn mock_json(
method: &str,
path: &str,
status: usize,
body: &str,
) -> (mockito::ServerGuard, mockito::Mock) {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock(method, path)
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
(server, mock)
}
async fn mock_list_repos(status: usize, body: &str) -> Result<Vec<GithubRepo>, String> {
let (server, mock) = mock_json(
"GET",
"/user/repos?per_page=100&sort=updated&page=1",
status,
body,
)
.await;
let result = github_list_repos_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_create_repo(status: usize, body: &str) -> Result<GithubRepo, String> {
let (server, mock) = mock_json("POST", "/user/repos", status, body).await;
let result = github_create_repo_with_base("token", "repo", false, &server.url()).await;
mock.assert_async().await;
result
}
async fn mock_user(status: usize, body: &str) -> Result<GitHubUser, String> {
let (server, mock) = mock_json("GET", "/user", status, body).await;
let result = github_get_user_with_base("token", &server.url()).await;
mock.assert_async().await;
result
}
#[tokio::test]
async fn test_github_list_repos_success() {
let repos = mock_list_repos(
200,
r#"[{"name":"my-repo","full_name":"user/my-repo","description":"A repo","private":false,"clone_url":"https://github.com/user/my-repo.git","html_url":"https://github.com/user/my-repo","updated_at":"2026-02-01T00:00:00Z"}]"#,
)
.await
.unwrap();
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].name, "my-repo");
}
#[tokio::test]
async fn test_github_list_repos_empty() {
let repos = mock_list_repos(200, "[]").await.unwrap();
assert!(repos.is_empty());
}
#[tokio::test]
async fn test_github_list_repos_auth_error() {
let err = mock_list_repos(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error"));
}
#[tokio::test]
async fn test_github_list_repos_paginated() {
let mut server = mockito::Server::new_async().await;
let repos_page1: Vec<serde_json::Value> = (0..100)
.map(|i| {
serde_json::json!({
"name": format!("repo-{}", i),
"full_name": format!("user/repo-{}", i),
"description": null,
"private": false,
"clone_url": format!("https://github.com/user/repo-{}.git", i),
"html_url": format!("https://github.com/user/repo-{}", i),
"updated_at": null
})
})
.collect();
let mock1 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=1")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(serde_json::to_string(&repos_page1).unwrap())
.create_async()
.await;
let mock2 = server
.mock("GET", "/user/repos?per_page=100&sort=updated&page=2")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{"name":"extra-repo","full_name":"user/extra-repo","description":null,"private":true,"clone_url":"https://github.com/user/extra-repo.git","html_url":"https://github.com/user/extra-repo","updated_at":null}]"#,
)
.create_async()
.await;
let repos = github_list_repos_with_base("token", &server.url())
.await
.unwrap();
mock1.assert_async().await;
mock2.assert_async().await;
assert_eq!(repos.len(), 101);
assert_eq!(repos[100].name, "extra-repo");
}
#[tokio::test]
async fn test_github_create_repo_success() {
let repo = mock_create_repo(
201,
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
)
.await
.unwrap();
assert_eq!(repo.name, "new-repo");
assert!(repo.private);
}
#[tokio::test]
async fn test_github_create_repo_name_exists() {
let err = mock_create_repo(
422,
r#"{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}"#,
)
.await
.unwrap_err();
assert!(err.contains("Repository name already exists"));
}
#[tokio::test]
async fn test_github_create_repo_server_error() {
let err = mock_create_repo(500, r#"{"message":"Internal Server Error"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 500"));
}
#[tokio::test]
async fn test_github_get_user_success() {
let user = mock_user(
200,
r#"{"login":"lucaong","name":"Luca Ongaro","avatar_url":"https://avatars.githubusercontent.com/u/12345"}"#,
)
.await
.unwrap();
assert_eq!(
user,
GitHubUser {
login: "lucaong".to_string(),
name: Some("Luca Ongaro".to_string()),
avatar_url: "https://avatars.githubusercontent.com/u/12345".to_string(),
}
);
}
#[tokio::test]
async fn test_github_get_user_unauthorized() {
let err = mock_user(401, r#"{"message":"Bad credentials"}"#)
.await
.unwrap_err();
assert!(err.contains("GitHub API error 401"));
}
#[tokio::test]
async fn test_github_get_user_null_name() {
let user = mock_user(
200,
r#"{"login":"bot-account","name":null,"avatar_url":"https://avatars.githubusercontent.com/u/99"}"#,
)
.await
.unwrap();
assert_eq!(user.login, "bot-account");
assert!(user.name.is_none());
}
}

View File

@@ -0,0 +1,242 @@
use serde::Deserialize;
use super::{DeviceFlowPollResult, DeviceFlowStart, GITHUB_CLIENT_ID};
/// Starts the GitHub OAuth device flow. Returns device code info for user authorization.
pub async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
github_device_flow_start_with_base("https://github.com").await
}
async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlowStart, String> {
let client = reqwest::Client::new();
let response = client
.post(format!("{}/login/device/code", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
.send()
.await
.map_err(|e| format!("Device flow request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
if status.as_u16() == 404 {
return Err(
"GitHub device flow not available. Ensure a GitHub App is registered with \
'Device authorization flow' enabled (Settings → Developer settings → GitHub Apps)."
.to_string(),
);
}
return Err(format!("Device flow start failed ({}): {}", status, body));
}
response
.json::<DeviceFlowStart>()
.await
.map_err(|e| format!("Failed to parse device flow response: {}", e))
}
/// Polls GitHub for the device flow authorization result.
pub async fn github_device_flow_poll(device_code: &str) -> Result<DeviceFlowPollResult, String> {
github_device_flow_poll_with_base(device_code, "https://github.com").await
}
async fn github_device_flow_poll_with_base(
device_code: &str,
base_url: &str,
) -> Result<DeviceFlowPollResult, String> {
let client = reqwest::Client::new();
let response = client
.post(format!("{}/login/oauth/access_token", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.form(&[
("client_id", GITHUB_CLIENT_ID),
("device_code", device_code),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.map_err(|e| format!("Device flow poll failed: {}", e))?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("Device flow poll HTTP error: {}", body));
}
#[derive(Deserialize)]
struct RawResponse {
access_token: Option<String>,
error: Option<String>,
}
let raw: RawResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse poll response: {}", e))?;
if let Some(token) = raw.access_token {
Ok(DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some(token),
error: None,
})
} else {
let error = raw.error.unwrap_or_else(|| "unknown".to_string());
let status = match error.as_str() {
"authorization_pending" | "slow_down" => "pending",
"expired_token" => "expired",
_ => "error",
};
Ok(DeviceFlowPollResult {
status: status.to_string(),
access_token: None,
error: Some(error),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn mock_json(
method: &str,
path: &str,
status: usize,
body: &str,
) -> (mockito::ServerGuard, mockito::Mock) {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock(method, path)
.with_status(status)
.with_header("content-type", "application/json")
.with_body(body)
.create_async()
.await;
(server, mock)
}
async fn mock_device_start(status: usize, body: &str) -> Result<DeviceFlowStart, String> {
let (server, mock) = mock_json("POST", "/login/device/code", status, body).await;
let result = github_device_flow_start_with_base(&server.url()).await;
mock.assert_async().await;
result
}
async fn mock_poll(body: &str) -> DeviceFlowPollResult {
let (server, mock) = mock_json("POST", "/login/oauth/access_token", 200, body).await;
let result = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
.await
.unwrap();
mock.assert_async().await;
result
}
#[tokio::test]
async fn test_github_device_flow_start_success() {
let start = mock_device_start(
200,
r#"{"device_code":"dev_abc","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
)
.await
.unwrap();
assert_eq!(
start,
DeviceFlowStart {
device_code: "dev_abc".to_string(),
user_code: "ABCD-1234".to_string(),
verification_uri: "https://github.com/login/device".to_string(),
expires_in: 900,
interval: 5,
}
);
}
#[tokio::test]
async fn test_github_device_flow_start_error() {
let err = mock_device_start(400, "bad request").await.unwrap_err();
assert!(err.contains("Device flow start failed"));
}
#[tokio::test]
async fn test_github_device_flow_start_404_gives_clear_message() {
let err = mock_device_start(404, r#"{"error":"Not Found"}"#)
.await
.unwrap_err();
assert!(err.contains("device flow not available"));
assert!(err.contains("Device authorization flow"));
}
#[tokio::test]
async fn test_github_device_flow_poll_complete() {
let poll =
mock_poll(r#"{"access_token":"gho_secret123","token_type":"bearer","scope":"repo"}"#)
.await;
assert_eq!(
poll,
DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some("gho_secret123".to_string()),
error: None,
}
);
}
#[tokio::test]
async fn test_github_device_flow_poll_pending() {
let poll = mock_poll(
r#"{"error":"authorization_pending","error_description":"The authorization request is still pending."}"#,
)
.await;
assert_eq!(poll.status, "pending");
assert_eq!(poll.error, Some("authorization_pending".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_slow_down() {
let poll = mock_poll(r#"{"error":"slow_down"}"#).await;
assert_eq!(poll.status, "pending");
assert_eq!(poll.error, Some("slow_down".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_expired() {
let poll = mock_poll(r#"{"error":"expired_token"}"#).await;
assert_eq!(poll.status, "expired");
assert_eq!(poll.error, Some("expired_token".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_other_error() {
let poll = mock_poll(r#"{"error":"access_denied"}"#).await;
assert_eq!(poll.status, "error");
assert_eq!(poll.error, Some("access_denied".to_string()));
}
#[tokio::test]
async fn test_github_device_flow_poll_http_error() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/login/oauth/access_token")
.with_status(503)
.with_body("Service Unavailable")
.create_async()
.await;
let err = github_device_flow_poll_with_base("dev_code_xyz", &server.url())
.await
.unwrap_err();
mock.assert_async().await;
assert!(err.contains("Device flow poll HTTP error"));
}
#[tokio::test]
async fn test_github_device_flow_poll_unknown_error() {
let poll = mock_poll(r#"{}"#).await;
assert_eq!(poll.status, "error");
assert_eq!(poll.error, Some("unknown".to_string()));
}
}

View File

@@ -0,0 +1,203 @@
use std::path::Path;
use std::process::Command;
/// Clones a GitHub repo to a local path using HTTPS + token auth.
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
let dest = Path::new(local_path);
if dest.exists()
&& dest
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(false)
{
return Err(format!(
"Destination '{}' already exists and is not empty",
local_path
));
}
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
let auth_url = inject_token_into_url(url, token)?;
let output = Command::new("git")
.args(["clone", "--progress", &auth_url, local_path])
.output()
.map_err(|e| format!("Failed to run git clone: {}", e))?;
if !output.status.success() {
// Clean up partial clone on failure
if dest.exists() {
let _ = std::fs::remove_dir_all(dest);
}
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git clone failed: {}", stderr));
}
// Configure the remote to use token auth for future pushes
configure_remote_auth(local_path, url, token)?;
Ok(format!("Cloned to {}", local_path))
}
/// Injects an OAuth token into an HTTPS GitHub URL.
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
if let Some(rest) = url.strip_prefix("https://github.com/") {
Ok(format!("https://oauth2:{}@github.com/{}", token, rest))
} else if let Some(rest) = url.strip_prefix("https://") {
// Handle URLs that already have a host
Ok(format!("https://oauth2:{}@{}", token, rest))
} else {
Err(format!(
"Unsupported URL format: {}. Use an HTTPS URL.",
url
))
}
}
/// Sets up the git remote to use token-based HTTPS auth.
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
let auth_url = inject_token_into_url(original_url, token)?;
let vault = Path::new(local_path);
let output = Command::new("git")
.args(["remote", "set-url", "origin", &auth_url])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to configure remote: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to set remote URL: {}", stderr));
}
// Also configure git user if not set
let _ = Command::new("git")
.args(["config", "user.email", "laputa@app.local"])
.current_dir(vault)
.output();
let _ = Command::new("git")
.args(["config", "user.name", "Laputa App"])
.current_dir(vault)
.output();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
fn clone_err_contains(url: &str, expected: &str) {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("dest");
let result = clone_repo(url, "token", dest.to_str().unwrap());
assert!(result.unwrap_err().contains(expected));
}
#[test]
fn test_inject_token_basic_github_url() {
let result = inject_token_into_url("https://github.com/user/repo.git", "gho_abc123");
assert_eq!(
result.unwrap(),
"https://oauth2:gho_abc123@github.com/user/repo.git"
);
}
#[test]
fn test_inject_token_generic_https_url() {
let result = inject_token_into_url("https://gitlab.com/user/repo.git", "glpat-abc");
assert_eq!(
result.unwrap(),
"https://oauth2:glpat-abc@gitlab.com/user/repo.git"
);
}
#[test]
fn test_inject_token_ssh_url_rejected() {
let err = inject_token_into_url("git@github.com:user/repo.git", "token").unwrap_err();
assert!(err.contains("Unsupported URL format"));
}
#[test]
fn test_inject_token_http_url_rejected() {
assert!(inject_token_into_url("http://github.com/user/repo.git", "token").is_err());
}
#[test]
fn test_inject_token_github_without_dot_git() {
let result = inject_token_into_url("https://github.com/user/repo", "tok");
assert_eq!(result.unwrap(), "https://oauth2:tok@github.com/user/repo");
}
#[test]
fn test_clone_repo_nonempty_dest() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("existing.txt"), "data").unwrap();
let result = clone_repo(
"https://github.com/test/repo.git",
"token",
dir.path().to_str().unwrap(),
);
assert!(result.unwrap_err().contains("not empty"));
}
#[test]
fn test_clone_repo_ssh_url_rejected() {
clone_err_contains("git@github.com:user/repo.git", "Unsupported URL format");
}
#[test]
fn test_clone_repo_empty_dest_allowed() {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("empty-dir");
std::fs::create_dir(&dest).unwrap();
let result = clone_repo(
"https://github.com/nonexistent/repo.git",
"token",
dest.to_str().unwrap(),
);
// Should fail at git clone, not at directory check
assert!(result.unwrap_err().contains("git clone failed"));
}
#[test]
fn test_configure_remote_auth_on_git_repo() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path();
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/user/repo.git",
])
.current_dir(path)
.output()
.unwrap();
configure_remote_auth(
path.to_str().unwrap(),
"https://github.com/user/repo.git",
"gho_test123",
)
.unwrap();
let output = StdCommand::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(path)
.output()
.unwrap();
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
}
}

139
src-tauri/src/github/mod.rs Normal file
View File

@@ -0,0 +1,139 @@
mod api;
mod auth;
mod clone;
use serde::{Deserialize, Serialize};
pub use api::{github_create_repo, github_get_user, github_list_repos};
pub use auth::{github_device_flow_poll, github_device_flow_start};
pub use clone::clone_repo;
/// GitHub App client ID for OAuth device flow.
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
/// Enable "Device authorization flow" under Optional features. Webhook can be disabled.
const GITHUB_CLIENT_ID: &str = "Ov23liwee215tDMs9u4L";
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GithubRepo {
pub name: String,
pub full_name: String,
pub description: Option<String>,
pub private: bool,
pub clone_url: String,
pub html_url: String,
pub updated_at: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct DeviceFlowStart {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct DeviceFlowPollResult {
pub status: String,
pub access_token: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubUser {
pub login: String,
pub name: Option<String>,
pub avatar_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_github_repo_serialization_roundtrip() {
let repo = GithubRepo {
name: "test-repo".to_string(),
full_name: "user/test-repo".to_string(),
description: Some("A test repo".to_string()),
private: true,
clone_url: "https://github.com/user/test-repo.git".to_string(),
html_url: "https://github.com/user/test-repo".to_string(),
updated_at: Some("2026-02-20T10:00:00Z".to_string()),
};
let json = serde_json::to_string(&repo).unwrap();
assert_eq!(serde_json::from_str::<GithubRepo>(&json).unwrap(), repo);
}
#[test]
fn test_github_repo_deserialization_null_fields() {
let json = r#"{"name":"r","full_name":"u/r","description":null,"private":false,"clone_url":"https://x","html_url":"https://y","updated_at":null}"#;
let repo: GithubRepo = serde_json::from_str(json).unwrap();
assert_eq!(repo.name, "r");
assert!(!repo.private);
assert!(repo.description.is_none());
assert!(repo.updated_at.is_none());
}
#[test]
fn test_device_flow_start_serialization_roundtrip() {
let start = DeviceFlowStart {
device_code: "dc_123".to_string(),
user_code: "ABCD-1234".to_string(),
verification_uri: "https://github.com/login/device".to_string(),
expires_in: 900,
interval: 5,
};
let json = serde_json::to_string(&start).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowStart>(&json).unwrap(),
start
);
}
#[test]
fn test_device_flow_poll_result_roundtrip() {
let complete = DeviceFlowPollResult {
status: "complete".to_string(),
access_token: Some("gho_abc123".to_string()),
error: None,
};
let json = serde_json::to_string(&complete).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
complete
);
let pending = DeviceFlowPollResult {
status: "pending".to_string(),
access_token: None,
error: Some("authorization_pending".to_string()),
};
let json = serde_json::to_string(&pending).unwrap();
assert_eq!(
serde_json::from_str::<DeviceFlowPollResult>(&json).unwrap(),
pending
);
}
#[test]
fn test_github_user_serialization_roundtrip() {
let user = GitHubUser {
login: "lucaong".to_string(),
name: Some("Luca Ongaro".to_string()),
avatar_url: "https://avatars.githubusercontent.com/u/123".to_string(),
};
let json = serde_json::to_string(&user).unwrap();
assert_eq!(serde_json::from_str::<GitHubUser>(&json).unwrap(), user);
}
#[test]
fn test_github_user_deserialization_null_name() {
let user: GitHubUser =
serde_json::from_str(r#"{"login":"bot","name":null,"avatar_url":"https://x"}"#)
.unwrap();
assert_eq!(user.login, "bot");
assert!(user.name.is_none());
}
}

View File

@@ -1,14 +1,33 @@
use serde::Serialize;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
static QMD_PATH_CACHE: Mutex<Option<String>> = Mutex::new(None);
/// Resolved qmd binary location: path + optional working directory.
/// The working dir is required for the bundled binary to find its node_modules.
#[derive(Debug, Clone)]
pub struct QmdBinary {
pub path: String,
pub work_dir: Option<PathBuf>,
}
/// Locate the qmd binary, checking known locations and PATH.
impl QmdBinary {
/// Create a `Command` pre-configured with the correct working directory.
pub fn command(&self) -> Command {
let mut cmd = Command::new(&self.path);
if let Some(ref dir) = self.work_dir {
cmd.current_dir(dir);
}
cmd
}
}
static QMD_CACHE: Mutex<Option<QmdBinary>> = Mutex::new(None);
/// Locate the qmd binary, checking bundled resource first, then known locations.
/// Caches the result for subsequent calls.
pub fn find_qmd_binary() -> Option<String> {
if let Ok(guard) = QMD_PATH_CACHE.lock() {
pub fn find_qmd_binary() -> Option<QmdBinary> {
if let Ok(guard) = QMD_CACHE.lock() {
if let Some(ref cached) = *guard {
return Some(cached.clone());
}
@@ -16,16 +35,22 @@ pub fn find_qmd_binary() -> Option<String> {
let result = find_qmd_binary_uncached();
if let Some(ref path) = result {
if let Ok(mut guard) = QMD_PATH_CACHE.lock() {
*guard = Some(path.clone());
if let Some(ref bin) = result {
if let Ok(mut guard) = QMD_CACHE.lock() {
*guard = Some(bin.clone());
}
}
result
}
fn find_qmd_binary_uncached() -> Option<String> {
fn find_qmd_binary_uncached() -> Option<QmdBinary> {
// 1. Check bundled binary (Tauri resource)
if let Some(bin) = find_bundled_qmd() {
return Some(bin);
}
// 2. Check known system locations
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()),
Some("/usr/local/bin/qmd".to_string()),
@@ -33,26 +58,159 @@ fn find_qmd_binary_uncached() -> Option<String> {
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(candidate);
return Some(QmdBinary {
path: candidate,
work_dir: None,
});
}
}
// Fallback: try PATH
// 3. Fallback: try PATH
Command::new("which")
.arg("qmd")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
Some(QmdBinary {
path: String::from_utf8_lossy(&o.stdout).trim().to_string(),
work_dir: None,
})
} else {
None
}
})
}
/// Clear the cached qmd path (e.g. after auto-install).
/// Look for the bundled qmd binary inside the app bundle or dev resources.
fn find_bundled_qmd() -> Option<QmdBinary> {
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
// macOS app bundle: <app>/Contents/MacOS/laputa → <app>/Contents/Resources/qmd/qmd
let bundle_dir = exe_dir.parent()?.join("Resources").join("qmd");
if let Some(bin) = prepare_bundled_dir(&bundle_dir) {
return Some(bin);
}
// Dev mode: use compile-time CARGO_MANIFEST_DIR for reliable path resolution
let dev_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("qmd");
if let Some(bin) = prepare_bundled_dir(&dev_dir) {
return Some(bin);
}
// Dev mode fallback: walk up from exe_dir to find the project root
let mut dir = exe_dir.to_path_buf();
for _ in 0..6 {
let qmd_dir = dir.join("resources").join("qmd");
if let Some(bin) = prepare_bundled_dir(&qmd_dir) {
return Some(bin);
}
if !dir.pop() {
break;
}
}
None
}
/// Validate a bundled qmd directory and prepare the binary for execution.
/// Sets execute permissions and removes macOS quarantine attributes.
fn prepare_bundled_dir(qmd_dir: &Path) -> Option<QmdBinary> {
let qmd_path = qmd_dir.join("qmd");
if !qmd_path.exists() {
return None;
}
ensure_executable(&qmd_path);
// Remove macOS quarantine attributes that block execution of bundled binaries
#[cfg(target_os = "macos")]
{
let _ = Command::new("xattr")
.args(["-rd", "com.apple.quarantine"])
.arg(qmd_dir)
.output();
}
Some(QmdBinary {
path: qmd_path.to_string_lossy().to_string(),
work_dir: Some(qmd_dir.to_path_buf()),
})
}
/// Ensure a file has execute permission.
#[cfg(unix)]
fn ensure_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() {
let mode = metadata.permissions().mode();
if mode & 0o111 == 0 {
let mut perms = metadata.permissions();
perms.set_mode(mode | 0o755);
let _ = std::fs::set_permissions(path, perms);
}
}
}
#[cfg(not(unix))]
fn ensure_executable(_path: &Path) {}
/// Try to install qmd globally using bun. Returns Ok if installation succeeded.
pub fn try_auto_install_qmd() -> Result<(), String> {
let bun = find_bun().ok_or("bun not found — cannot auto-install qmd")?;
log::info!("Auto-installing qmd via bun...");
let output = Command::new(&bun)
.args(["install", "-g", "qmd"])
.output()
.map_err(|e| format!("Failed to run bun install: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("bun install -g qmd failed: {stderr}"));
}
// Clear cache so the newly installed binary is discovered
clear_qmd_cache();
log::info!("qmd auto-install succeeded");
Ok(())
}
/// Locate bun binary for auto-installing qmd.
fn find_bun() -> Option<PathBuf> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/bun")),
Some(PathBuf::from("/opt/homebrew/bin/bun")),
Some(PathBuf::from("/usr/local/bin/bun")),
];
for candidate in candidates.into_iter().flatten() {
if candidate.exists() {
return Some(candidate);
}
}
// Fallback: try PATH
Command::new("which")
.arg("bun")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(PathBuf::from(
String::from_utf8_lossy(&o.stdout).trim().to_string(),
))
} else {
None
}
})
}
/// Clear the cached qmd binary (e.g. after path changes or installation).
pub fn clear_qmd_cache() {
if let Ok(mut guard) = QMD_PATH_CACHE.lock() {
if let Ok(mut guard) = QMD_CACHE.lock() {
*guard = None;
}
}
@@ -69,7 +227,7 @@ pub struct IndexStatus {
/// Check whether the vault has a qmd index and its status.
pub fn check_index_status(vault_path: &str) -> IndexStatus {
let qmd_bin = match find_qmd_binary() {
let qmd = match find_qmd_binary() {
Some(b) => b,
None => {
return IndexStatus {
@@ -84,7 +242,7 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
};
let vault_name = vault_dir_name(vault_path);
let output = Command::new(&qmd_bin).args(["status"]).output();
let output = qmd.command().args(["status"]).output();
match output {
Ok(o) if o.status.success() => {
@@ -182,11 +340,12 @@ fn extract_first_number(s: &str) -> Option<usize> {
/// Ensure a qmd collection exists for this vault. Creates one if missing.
pub fn ensure_collection(vault_path: &str) -> Result<(), String> {
let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?;
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
let vault_name = vault_dir_name(vault_path);
// Check if collection already exists
let output = Command::new(&qmd_bin)
let output = qmd
.command()
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
@@ -199,7 +358,7 @@ pub fn ensure_collection(vault_path: &str) -> Result<(), String> {
}
// Create collection
Command::new(&qmd_bin)
qmd.command()
.args([
"collection",
"add",
@@ -229,7 +388,7 @@ pub fn run_full_index<F>(vault_path: &str, on_progress: F) -> Result<(), String>
where
F: Fn(IndexingProgress),
{
let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?;
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
ensure_collection(vault_path)?;
@@ -242,7 +401,8 @@ where
error: None,
});
let update_output = Command::new(&qmd_bin)
let update_output = qmd
.command()
.args(["update"])
.output()
.map_err(|e| format!("qmd update failed: {e}"))?;
@@ -281,7 +441,8 @@ where
error: None,
});
let embed_output = Command::new(&qmd_bin)
let embed_output = qmd
.command()
.args(["embed"])
.output()
.map_err(|e| format!("qmd embed failed: {e}"))?;
@@ -323,11 +484,12 @@ fn parse_indexed_count(update_output: &str) -> usize {
/// Run incremental update for a single file change.
pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?;
let qmd = find_qmd_binary().ok_or("qmd not installed")?;
// Verify collection exists
let vault_name = vault_dir_name(vault_path);
let list_output = Command::new(&qmd_bin)
let list_output = qmd
.command()
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
@@ -340,7 +502,8 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
}
}
let output = Command::new(&qmd_bin)
let output = qmd
.command()
.args(["update"])
.output()
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
@@ -353,58 +516,31 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
Ok(())
}
/// Attempt to auto-install qmd via bun. Returns Ok if successful.
pub fn auto_install_qmd() -> Result<String, String> {
// Find bun
let bun = find_bun().ok_or("bun not installed — cannot auto-install qmd")?;
let output = Command::new(&bun)
.args(["install", "-g", "qmd"])
.output()
.map_err(|e| format!("Failed to install qmd: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd installation failed: {stderr}"));
}
// Clear cache so find_qmd_binary() re-discovers
clear_qmd_cache();
match find_qmd_binary() {
Some(path) => Ok(path),
None => Err("qmd installed but binary not found".to_string()),
}
}
fn find_bun() -> Option<String> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/bun").to_string_lossy().to_string()),
Some("/opt/homebrew/bin/bun".to_string()),
Some("/usr/local/bin/bun".to_string()),
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(candidate);
}
}
Command::new("which")
.arg("bun")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn qmd_binary_command_sets_work_dir() {
let qmd = QmdBinary {
path: "/bin/echo".to_string(),
work_dir: Some(PathBuf::from("/tmp")),
};
let cmd = qmd.command();
let dbg = format!("{:?}", cmd);
assert!(dbg.contains("/bin/echo"));
}
#[test]
fn qmd_binary_command_no_work_dir() {
let qmd = QmdBinary {
path: "/bin/echo".to_string(),
work_dir: None,
};
let output = qmd.command().arg("hello").output().unwrap();
assert!(output.status.success());
}
#[test]
fn vault_dir_name_extracts_last_segment() {
assert_eq!(vault_dir_name("/Users/luca/Laputa"), "laputa");
@@ -483,4 +619,83 @@ Collections
assert_eq!(parse_indexed_count("Indexed 342 files in 1.2s"), 342);
assert_eq!(parse_indexed_count("No output"), 0);
}
#[test]
fn ensure_executable_sets_permission() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test-bin");
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
// Start with no execute permission
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
assert_eq!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
ensure_executable(&file);
assert_ne!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0);
}
}
#[test]
fn ensure_executable_noop_when_already_executable() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test-bin");
fs::write(&file, "#!/bin/sh\necho ok").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap();
ensure_executable(&file);
let mode = fs::metadata(&file).unwrap().permissions().mode();
assert_ne!(mode & 0o111, 0);
}
}
#[test]
fn prepare_bundled_dir_returns_none_for_missing_binary() {
let dir = tempfile::tempdir().unwrap();
assert!(prepare_bundled_dir(dir.path()).is_none());
}
#[test]
fn prepare_bundled_dir_finds_and_prepares_binary() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let qmd_path = dir.path().join("qmd");
fs::write(&qmd_path, "#!/bin/sh\necho ok").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&qmd_path, fs::Permissions::from_mode(0o644)).unwrap();
}
let result = prepare_bundled_dir(dir.path());
assert!(result.is_some());
let bin = result.unwrap();
assert!(bin.path.ends_with("qmd"));
assert_eq!(bin.work_dir.unwrap(), dir.path());
// Verify execute permission was set
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_ne!(
fs::metadata(&qmd_path).unwrap().permissions().mode() & 0o111,
0
);
}
}
#[test]
fn find_bun_returns_some_if_available() {
// This test may succeed or fail depending on the system.
// It verifies the function doesn't panic.
let _ = find_bun();
}
}

View File

@@ -1,5 +1,6 @@
pub mod ai_chat;
pub mod claude_cli;
mod commands;
pub mod frontmatter;
pub mod git;
pub mod github;
@@ -10,471 +11,14 @@ pub mod search;
pub mod settings;
pub mod theme;
pub mod vault;
pub mod vault_config;
pub mod vault_list;
use std::borrow::Cow;
use std::path::Path;
use std::process::Child;
use std::sync::Mutex;
use ai_chat::{AiChatRequest, AiChatResponse};
use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent};
use frontmatter::FrontmatterValue;
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile};
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use indexing::{IndexStatus, IndexingProgress};
use search::SearchResponse;
use settings::Settings;
use theme::{ThemeFile, VaultSettings};
use vault::{RenameResult, VaultEntry};
use vault_list::VaultList;
/// 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.
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));
}
}
Cow::Borrowed(path)
}
#[tauri::command]
fn list_vault(path: String) -> Result<Vec<VaultEntry>, String> {
let path = expand_tilde(&path);
vault::scan_vault_cached(Path::new(path.as_ref()))
}
#[tauri::command]
fn get_note_content(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::get_note_content(Path::new(path.as_ref()))
}
#[tauri::command]
fn save_note_content(path: String, content: String) -> Result<(), String> {
let path = expand_tilde(&path);
vault::save_note_content(&path, &content)
}
#[tauri::command]
fn update_frontmatter(
path: String,
key: String,
value: FrontmatterValue,
) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::update_frontmatter(&path, &key, value)
}
#[tauri::command]
fn delete_frontmatter_property(path: String, key: String) -> Result<String, String> {
let path = expand_tilde(&path);
frontmatter::delete_frontmatter_property(&path, &key)
}
#[tauri::command]
fn get_file_history(vault_path: String, path: String) -> Result<Vec<GitCommit>, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_history(&vault_path, &path)
}
#[tauri::command]
fn get_modified_files(vault_path: String) -> Result<Vec<ModifiedFile>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_modified_files(&vault_path)
}
#[tauri::command]
fn get_file_diff(vault_path: String, path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff(&vault_path, &path)
}
#[tauri::command]
fn get_file_diff_at_commit(
vault_path: String,
path: String,
commit_hash: String,
) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
let path = expand_tilde(&path);
git::get_file_diff_at_commit(&vault_path, &path, &commit_hash)
}
#[tauri::command]
fn git_commit(vault_path: String, message: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit(&vault_path, &message)
}
fn parse_build_label(version: &str) -> 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(),
}
}
#[tauri::command]
fn get_build_number(app_handle: tauri::AppHandle) -> String {
let version = app_handle.package_info().version.to_string();
parse_build_label(&version)
}
#[tauri::command]
fn get_last_commit_info(vault_path: String) -> Result<Option<LastCommitInfo>, String> {
let vault_path = expand_tilde(&vault_path);
git::get_last_commit_info(&vault_path)
}
#[tauri::command]
fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
let vault_path = expand_tilde(&vault_path);
git::git_pull(&vault_path)
}
#[tauri::command]
fn git_resolve_conflict(vault_path: String, file: String, strategy: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
git::git_resolve_conflict(&vault_path, &file, &strategy)
}
#[tauri::command]
fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit_conflict_resolution(&vault_path)
}
#[tauri::command]
fn git_push(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_push(&vault_path)
}
#[tauri::command]
async fn ai_chat(request: AiChatRequest) -> Result<AiChatResponse, String> {
ai_chat::send_chat(request).await
}
#[tauri::command]
fn check_claude_cli() -> ClaudeCliStatus {
claude_cli::check_cli()
}
#[tauri::command]
async fn stream_claude_chat(
app_handle: tauri::AppHandle,
request: ChatStreamRequest,
) -> Result<String, String> {
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| {
let _ = app_handle.emit("claude-stream", &event);
})
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
async fn stream_claude_agent(
app_handle: tauri::AppHandle,
request: AgentStreamRequest,
) -> Result<String, String> {
use tauri::Emitter;
tokio::task::spawn_blocking(move || {
claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| {
let _ = app_handle.emit("claude-agent-stream", &event);
})
})
.await
.map_err(|e| format!("Task failed: {e}"))?
}
#[tauri::command]
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::copy_image_to_vault(&vault_path, &source_path)
}
#[tauri::command]
fn rename_note(
vault_path: String,
old_path: String,
new_title: String,
) -> Result<RenameResult, String> {
let vault_path = expand_tilde(&vault_path);
let old_path = expand_tilde(&old_path);
vault::rename_note(&vault_path, &old_path, &new_title)
}
#[tauri::command]
fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
let vault_path = expand_tilde(&vault_path);
vault::purge_trash(&vault_path)
}
#[tauri::command]
fn delete_note(path: String) -> Result<String, String> {
let path = expand_tilde(&path);
vault::delete_note(&path)
}
#[tauri::command]
fn migrate_is_a_to_type(vault_path: String) -> Result<usize, String> {
let vault_path = expand_tilde(&vault_path);
vault::migrate_is_a_to_type(&vault_path)
}
#[tauri::command]
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(),
};
vault::create_getting_started_vault(&path)
}
#[tauri::command]
fn check_vault_exists(path: String) -> bool {
let path = expand_tilde(&path);
vault::vault_exists(&path)
}
#[tauri::command]
fn get_default_vault_path() -> Result<String, String> {
vault::default_vault_path().map(|p| p.to_string_lossy().to_string())
}
#[tauri::command]
fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
count += 1;
}
Ok(count)
}
#[tauri::command]
fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let mut count = 0;
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(
&path,
"Trashed at",
FrontmatterValue::String(now.clone()),
)?;
count += 1;
}
Ok(count)
}
#[tauri::command]
fn update_menu_state(app_handle: tauri::AppHandle, has_active_note: bool) -> Result<(), String> {
menu::set_note_items_enabled(&app_handle, has_active_note);
Ok(())
}
#[tauri::command]
fn get_settings() -> Result<Settings, String> {
settings::get_settings()
}
#[tauri::command]
fn save_settings(settings: Settings) -> Result<(), String> {
settings::save_settings(settings)
}
#[tauri::command]
fn load_vault_list() -> Result<VaultList, String> {
vault_list::load_vault_list()
}
#[tauri::command]
fn save_vault_list(list: VaultList) -> Result<(), String> {
vault_list::save_vault_list(&list)
}
#[tauri::command]
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
github::github_list_repos(&token).await
}
#[tauri::command]
async fn github_create_repo(
token: String,
name: String,
private: bool,
) -> Result<GithubRepo, String> {
github::github_create_repo(&token, &name, private).await
}
#[tauri::command]
fn clone_repo(url: String, token: String, local_path: String) -> Result<String, String> {
let local_path = expand_tilde(&local_path);
github::clone_repo(&url, &token, &local_path)
}
#[tauri::command]
async fn github_device_flow_start() -> Result<DeviceFlowStart, String> {
github::github_device_flow_start().await
}
#[tauri::command]
async fn github_device_flow_poll(device_code: String) -> Result<DeviceFlowPollResult, String> {
github::github_device_flow_poll(&device_code).await
}
#[tauri::command]
async fn github_get_user(token: String) -> Result<GitHubUser, String> {
github::github_get_user(&token).await
}
#[tauri::command]
async fn search_vault(
vault_path: String,
query: String,
mode: String,
limit: Option<usize>,
) -> Result<SearchResponse, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
let limit = limit.unwrap_or(20);
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
.await
.map_err(|e| format!("Search task failed: {}", e))?
}
#[tauri::command]
fn get_index_status(vault_path: String) -> IndexStatus {
let vault_path = expand_tilde(&vault_path);
indexing::check_index_status(&vault_path)
}
#[tauri::command]
async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Result<(), String> {
use tauri::Emitter;
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
// Auto-install qmd if not available
if indexing::find_qmd_binary().is_none() {
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "installing".to_string(),
current: 0,
total: 0,
done: false,
error: None,
},
);
match indexing::auto_install_qmd() {
Ok(_) => log::info!("qmd auto-installed successfully"),
Err(e) => {
log::warn!("qmd auto-install failed: {e}");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "error".to_string(),
current: 0,
total: 0,
done: true,
error: Some(format!("qmd not available: {e}")),
},
);
return Err(e);
}
}
}
indexing::run_full_index(&vault_path, |progress| {
let _ = app_handle.emit("indexing-progress", &progress);
})
})
.await
.map_err(|e| format!("Indexing task failed: {e}"))?
}
#[tauri::command]
async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
.await
.map_err(|e| format!("Incremental index failed: {e}"))?
}
struct WsBridgeChild(Mutex<Option<Child>>);
#[tauri::command]
async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || mcp::register_mcp(&vault_path))
.await
.map_err(|e| format!("Registration task failed: {e}"))?
}
#[tauri::command]
fn list_themes(vault_path: String) -> Result<Vec<ThemeFile>, String> {
let vault_path = expand_tilde(&vault_path);
theme::list_themes(&vault_path)
}
#[tauri::command]
fn get_theme(vault_path: String, theme_id: String) -> Result<ThemeFile, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_theme(&vault_path, &theme_id)
}
#[tauri::command]
fn get_vault_settings(vault_path: String) -> Result<VaultSettings, String> {
let vault_path = expand_tilde(&vault_path);
theme::get_vault_settings(&vault_path)
}
#[tauri::command]
fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::save_vault_settings(&vault_path, settings)
}
#[tauri::command]
fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::set_active_theme(&vault_path, theme_id.as_deref())
}
#[tauri::command]
fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_theme(&vault_path, source_id.as_deref())
}
#[tauri::command]
fn create_vault_theme(vault_path: String, name: Option<String>) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
theme::create_vault_theme(&vault_path, name.as_deref())
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
@@ -500,11 +44,17 @@ fn run_startup_tasks() {
"Migrated is_a to type on startup",
vault::migrate_is_a_to_type(vp_str),
);
log_startup_result(
"Migrated hidden_sections to visible property",
vault_config::migrate_hidden_sections_to_visible(vp_str),
);
// Seed _themes/ with built-in JSON themes (legacy) if missing
theme::seed_default_themes(vp_str);
// Seed theme/ with built-in vault theme notes if missing
theme::seed_vault_themes(vp_str);
// Seed type/theme.md so the Theme type has an icon in the sidebar
let _ = theme::ensure_theme_type_definition(vp_str);
// Register Laputa MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
@@ -513,61 +63,6 @@ fn run_startup_tasks() {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_tilde_with_subpath() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~/Documents/vault");
assert_eq!(result, format!("{}/Documents/vault", home.display()));
}
#[test]
fn expand_tilde_alone() {
let home = dirs::home_dir().unwrap();
let result = expand_tilde("~");
assert_eq!(result, home.to_string_lossy());
}
#[test]
fn expand_tilde_noop_for_absolute_path() {
let result = expand_tilde("/usr/local/bin");
assert_eq!(result, "/usr/local/bin");
}
#[test]
fn expand_tilde_noop_for_relative_path() {
let result = expand_tilde("some/relative/path");
assert_eq!(result, "some/relative/path");
}
#[test]
fn expand_tilde_noop_for_tilde_in_middle() {
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?");
}
}
fn spawn_ws_bridge(app: &mut tauri::App) {
use tauri::Manager;
let vault_path = dirs::home_dir()
@@ -594,11 +89,6 @@ pub fn run() {
.level(log::LevelFilter::Info)
.build(),
)?;
// Open devtools automatically in debug builds
// use tauri::Manager;
// if let Some(window) = app.get_webview_window("main") {
// window.open_devtools();
// }
}
app.handle().plugin(tauri_plugin_dialog::init())?;
@@ -617,60 +107,68 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
list_vault,
get_note_content,
save_note_content,
update_frontmatter,
delete_frontmatter_property,
rename_note,
get_file_history,
get_modified_files,
get_file_diff,
get_file_diff_at_commit,
git_commit,
get_build_number,
get_last_commit_info,
git_pull,
git_push,
git_resolve_conflict,
git_commit_conflict_resolution,
ai_chat,
check_claude_cli,
stream_claude_chat,
stream_claude_agent,
save_image,
copy_image_to_vault,
purge_trash,
delete_note,
migrate_is_a_to_type,
batch_archive_notes,
batch_trash_notes,
get_settings,
update_menu_state,
save_settings,
load_vault_list,
save_vault_list,
github_list_repos,
github_create_repo,
clone_repo,
github_device_flow_start,
github_device_flow_poll,
github_get_user,
search_vault,
get_index_status,
start_indexing,
trigger_incremental_index,
create_getting_started_vault,
check_vault_exists,
get_default_vault_path,
register_mcp_tools,
list_themes,
get_theme,
get_vault_settings,
save_vault_settings,
set_active_theme,
create_theme,
create_vault_theme
commands::list_vault,
commands::get_note_content,
commands::save_note_content,
commands::update_frontmatter,
commands::delete_frontmatter_property,
commands::rename_note,
commands::get_file_history,
commands::get_modified_files,
commands::get_file_diff,
commands::get_file_diff_at_commit,
commands::get_vault_pulse,
commands::git_commit,
commands::get_build_number,
commands::get_last_commit_info,
commands::git_pull,
commands::git_push,
commands::get_conflict_files,
commands::get_conflict_mode,
commands::git_resolve_conflict,
commands::git_commit_conflict_resolution,
commands::ai_chat,
commands::check_claude_cli,
commands::stream_claude_chat,
commands::stream_claude_agent,
commands::save_image,
commands::copy_image_to_vault,
commands::purge_trash,
commands::delete_note,
commands::migrate_is_a_to_type,
commands::batch_archive_notes,
commands::batch_trash_notes,
commands::get_settings,
commands::update_menu_state,
commands::save_settings,
commands::load_vault_list,
commands::save_vault_list,
commands::github_list_repos,
commands::github_create_repo,
commands::clone_repo,
commands::github_device_flow_start,
commands::github_device_flow_poll,
commands::github_get_user,
commands::search_vault,
commands::get_index_status,
commands::start_indexing,
commands::trigger_incremental_index,
commands::create_getting_started_vault,
commands::check_vault_exists,
commands::get_default_vault_path,
commands::register_mcp_tools,
commands::check_mcp_status,
commands::list_themes,
commands::get_theme,
commands::get_vault_settings,
commands::save_vault_settings,
commands::set_active_theme,
commands::create_theme,
commands::create_vault_theme,
commands::ensure_vault_themes,
commands::restore_default_themes,
commands::get_vault_config,
commands::save_vault_config
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -1,6 +1,19 @@
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
/// Status of the MCP server installation.
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum McpStatus {
/// MCP is registered in Claude config and server files exist.
Installed,
/// MCP server files or config are missing but can be installed.
NotInstalled,
/// Claude CLI is not installed — must install that first.
NoClaudeCli,
}
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = Command::new("which")
@@ -144,6 +157,57 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
Ok(was_update)
}
/// Check whether the MCP server is properly installed and registered.
///
/// Returns `Installed` when the laputa entry exists in `~/.claude/mcp.json`
/// and the referenced index.js file is present. Returns `NoClaudeCli` when
/// the Claude CLI binary cannot be found. Otherwise returns `NotInstalled`.
pub fn check_mcp_status() -> McpStatus {
// Check Claude CLI first — no point installing MCP if Claude isn't available
if crate::claude_cli::find_claude_binary().is_err() {
return McpStatus::NoClaudeCli;
}
let config_path = match dirs::home_dir() {
Some(h) => h.join(".claude").join("mcp.json"),
None => return McpStatus::NotInstalled,
};
if !config_path.exists() {
return McpStatus::NotInstalled;
}
let raw = match std::fs::read_to_string(&config_path) {
Ok(r) => r,
Err(_) => return McpStatus::NotInstalled,
};
let config: serde_json::Value = match serde_json::from_str(&raw) {
Ok(c) => c,
Err(_) => return McpStatus::NotInstalled,
};
let entry = &config["mcpServers"]["laputa"];
if entry.is_null() {
return McpStatus::NotInstalled;
}
// Verify the referenced index.js actually exists on disk
if let Some(index_js) = entry["args"]
.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
{
if !Path::new(index_js).exists() {
return McpStatus::NotInstalled;
}
} else {
return McpStatus::NotInstalled;
}
McpStatus::Installed
}
#[cfg(test)]
mod tests {
use super::*;
@@ -314,4 +378,29 @@ mod tests {
// With empty config list, there were no updates, so status should be "registered"
assert_eq!(status, "registered");
}
#[test]
fn check_mcp_status_returns_valid_variant() {
// On a dev machine with Claude CLI and MCP registered, this should be Installed.
// On CI without Claude it might be NoClaudeCli. Either way it must not panic.
let status = check_mcp_status();
assert!(
matches!(
status,
McpStatus::Installed | McpStatus::NotInstalled | McpStatus::NoClaudeCli
),
"unexpected status: {:?}",
status
);
}
#[test]
fn mcp_status_serializes_to_snake_case() {
let json = serde_json::to_string(&McpStatus::Installed).unwrap();
assert_eq!(json, r#""installed""#);
let json = serde_json::to_string(&McpStatus::NotInstalled).unwrap();
assert_eq!(json, r#""not_installed""#);
let json = serde_json::to_string(&McpStatus::NoClaudeCli).unwrap();
assert_eq!(json, r#""no_claude_cli""#);
}
}

View File

@@ -5,51 +5,109 @@ use tauri::{
// Custom menu item IDs that emit events to the frontend.
const APP_SETTINGS: &str = "app-settings";
const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
const FILE_NEW_NOTE: &str = "file-new-note";
const FILE_NEW_TYPE: &str = "file-new-type";
const FILE_DAILY_NOTE: &str = "file-daily-note";
const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_SAVE: &str = "file-save";
const FILE_CLOSE_TAB: &str = "file-close-tab";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor";
const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff";
const VIEW_EDITOR_ONLY: &str = "view-editor-only";
const VIEW_EDITOR_LIST: &str = "view-editor-list";
const VIEW_ALL: &str = "view-all";
const VIEW_TOGGLE_INSPECTOR: &str = "view-toggle-inspector";
const VIEW_TOGGLE_PROPERTIES: &str = "view-toggle-properties";
const VIEW_TOGGLE_AI_CHAT: &str = "view-toggle-ai-chat";
const VIEW_TOGGLE_BACKLINKS: &str = "view-toggle-backlinks";
const VIEW_COMMAND_PALETTE: &str = "view-command-palette";
const VIEW_ZOOM_IN: &str = "view-zoom-in";
const VIEW_ZOOM_OUT: &str = "view-zoom-out";
const VIEW_ZOOM_RESET: &str = "view-zoom-reset";
const NOTE_ARCHIVE: &str = "note-archive";
const NOTE_TRASH: &str = "note-trash";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const VIEW_GO_BACK: &str = "view-go-back";
const VIEW_GO_FORWARD: &str = "view-go-forward";
const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
const GO_ALL_NOTES: &str = "go-all-notes";
const GO_FAVORITES: &str = "go-favorites";
const GO_ARCHIVED: &str = "go-archived";
const GO_TRASH: &str = "go-trash";
const GO_CHANGES: &str = "go-changes";
const NOTE_ARCHIVE: &str = "note-archive";
const NOTE_TRASH: &str = "note-trash";
const VAULT_OPEN: &str = "vault-open";
const VAULT_REMOVE: &str = "vault-remove";
const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started";
const VAULT_NEW_THEME: &str = "vault-new-theme";
const VAULT_RESTORE_DEFAULT_THEMES: &str = "vault-restore-default-themes";
const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
APP_CHECK_FOR_UPDATES,
FILE_NEW_NOTE,
FILE_NEW_TYPE,
FILE_DAILY_NOTE,
FILE_QUICK_OPEN,
FILE_SAVE,
FILE_CLOSE_TAB,
NOTE_ARCHIVE,
NOTE_TRASH,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
VIEW_EDITOR_LIST,
VIEW_ALL,
VIEW_TOGGLE_INSPECTOR,
VIEW_TOGGLE_PROPERTIES,
VIEW_TOGGLE_AI_CHAT,
VIEW_TOGGLE_BACKLINKS,
VIEW_COMMAND_PALETTE,
VIEW_ZOOM_IN,
VIEW_ZOOM_OUT,
VIEW_ZOOM_RESET,
VIEW_GO_BACK,
VIEW_GO_FORWARD,
APP_CHECK_FOR_UPDATES,
GO_ALL_NOTES,
GO_FAVORITES,
GO_ARCHIVED,
GO_TRASH,
GO_CHANGES,
NOTE_ARCHIVE,
NOTE_TRASH,
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_NEW_THEME,
VAULT_RESTORE_DEFAULT_THEMES,
VAULT_COMMIT_PUSH,
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
];
/// IDs of menu items that should be disabled when no note tab is active.
const NOTE_DEPENDENT_IDS: &[&str] = &[FILE_SAVE, FILE_CLOSE_TAB, NOTE_ARCHIVE, NOTE_TRASH];
const NOTE_DEPENDENT_IDS: &[&str] = &[
FILE_SAVE,
FILE_CLOSE_TAB,
NOTE_ARCHIVE,
NOTE_TRASH,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_TOGGLE_BACKLINKS,
];
/// IDs of menu items that depend on having uncommitted changes.
const GIT_COMMIT_DEPENDENT_IDS: &[&str] = &[VAULT_COMMIT_PUSH];
/// IDs of menu items that depend on having merge conflicts.
const GIT_CONFLICT_DEPENDENT_IDS: &[&str] = &[VAULT_RESOLVE_CONFLICTS];
type MenuResult = Result<Submenu<tauri::Wry>, Box<dyn std::error::Error>>;
@@ -84,6 +142,9 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_NEW_NOTE)
.accelerator("CmdOrCtrl+N")
.build(app)?;
let new_type = MenuItemBuilder::new("New Type")
.id(FILE_NEW_TYPE)
.build(app)?;
let daily_note = MenuItemBuilder::new("Open Today's Note")
.id(FILE_DAILY_NOTE)
.accelerator("CmdOrCtrl+J")
@@ -100,25 +161,14 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_CLOSE_TAB)
.accelerator("CmdOrCtrl+W")
.build(app)?;
let archive_note = MenuItemBuilder::new("Archive Note")
.id(NOTE_ARCHIVE)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let trash_note = MenuItemBuilder::new("Trash Note")
.id(NOTE_TRASH)
.accelerator("CmdOrCtrl+Backspace")
.build(app)?;
Ok(SubmenuBuilder::new(app, "File")
.item(&new_note)
.item(&new_type)
.item(&daily_note)
.item(&quick_open)
.separator()
.item(&save)
.separator()
.item(&archive_note)
.item(&trash_note)
.separator()
.item(&close_tab)
.build()?)
}
@@ -128,6 +178,9 @@ fn build_edit_menu(app: &App) -> MenuResult {
.id(EDIT_FIND_IN_VAULT)
.accelerator("CmdOrCtrl+Shift+F")
.build(app)?;
let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode")
.id(EDIT_TOGGLE_DIFF)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Edit")
.undo()
@@ -140,6 +193,7 @@ fn build_edit_menu(app: &App) -> MenuResult {
.select_all()
.separator()
.item(&find_in_vault)
.item(&toggle_diff)
.build()?)
}
@@ -156,8 +210,8 @@ fn build_view_menu(app: &App) -> MenuResult {
.id(VIEW_ALL)
.accelerator("CmdOrCtrl+3")
.build(app)?;
let toggle_inspector = MenuItemBuilder::new("Toggle Inspector")
.id(VIEW_TOGGLE_INSPECTOR)
let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel")
.id(VIEW_TOGGLE_PROPERTIES)
.build(app)?;
let command_palette = MenuItemBuilder::new("Command Palette")
.id(VIEW_COMMAND_PALETTE)
@@ -175,6 +229,34 @@ fn build_view_menu(app: &App) -> MenuResult {
.id(VIEW_ZOOM_RESET)
.accelerator("CmdOrCtrl+0")
.build(app)?;
Ok(SubmenuBuilder::new(app, "View")
.item(&editor_only)
.item(&editor_list)
.item(&all_panels)
.separator()
.item(&toggle_properties)
.separator()
.item(&zoom_in)
.item(&zoom_out)
.item(&zoom_reset)
.separator()
.item(&command_palette)
.build()?)
}
fn build_go_menu(app: &App) -> MenuResult {
let all_notes = MenuItemBuilder::new("All Notes")
.id(GO_ALL_NOTES)
.build(app)?;
let favorites = MenuItemBuilder::new("Favorites")
.id(GO_FAVORITES)
.build(app)?;
let archived = MenuItemBuilder::new("Archived")
.id(GO_ARCHIVED)
.build(app)?;
let trash = MenuItemBuilder::new("Trash").id(GO_TRASH).build(app)?;
let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?;
let go_back = MenuItemBuilder::new("Go Back")
.id(VIEW_GO_BACK)
.accelerator("CmdOrCtrl+[")
@@ -184,21 +266,92 @@ fn build_view_menu(app: &App) -> MenuResult {
.accelerator("CmdOrCtrl+]")
.build(app)?;
Ok(SubmenuBuilder::new(app, "View")
.item(&editor_only)
.item(&editor_list)
.item(&all_panels)
.separator()
.item(&toggle_inspector)
Ok(SubmenuBuilder::new(app, "Go")
.item(&all_notes)
.item(&favorites)
.item(&archived)
.item(&trash)
.item(&changes)
.separator()
.item(&go_back)
.item(&go_forward)
.build()?)
}
fn build_note_menu(app: &App) -> MenuResult {
let archive_note = MenuItemBuilder::new("Archive Note")
.id(NOTE_ARCHIVE)
.accelerator("CmdOrCtrl+E")
.build(app)?;
let trash_note = MenuItemBuilder::new("Trash Note")
.id(NOTE_TRASH)
.accelerator("CmdOrCtrl+Backspace")
.build(app)?;
let toggle_raw_editor = MenuItemBuilder::new("Toggle Raw Editor")
.id(EDIT_TOGGLE_RAW_EDITOR)
.accelerator("CmdOrCtrl+\\")
.build(app)?;
let toggle_ai_chat = MenuItemBuilder::new("Toggle AI Chat")
.id(VIEW_TOGGLE_AI_CHAT)
.accelerator("CmdOrCtrl+I")
.build(app)?;
let toggle_backlinks = MenuItemBuilder::new("Toggle Backlinks")
.id(VIEW_TOGGLE_BACKLINKS)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Note")
.item(&archive_note)
.item(&trash_note)
.separator()
.item(&zoom_in)
.item(&zoom_out)
.item(&zoom_reset)
.item(&toggle_raw_editor)
.item(&toggle_ai_chat)
.item(&toggle_backlinks)
.build()?)
}
fn build_vault_menu(app: &App) -> MenuResult {
let open_vault = MenuItemBuilder::new("Open Vault…")
.id(VAULT_OPEN)
.build(app)?;
let remove_vault = MenuItemBuilder::new("Remove Vault from List")
.id(VAULT_REMOVE)
.build(app)?;
let restore_getting_started = MenuItemBuilder::new("Restore Getting Started")
.id(VAULT_RESTORE_GETTING_STARTED)
.build(app)?;
let new_theme = MenuItemBuilder::new("New Theme")
.id(VAULT_NEW_THEME)
.build(app)?;
let restore_default_themes = MenuItemBuilder::new("Restore Default Themes")
.id(VAULT_RESTORE_DEFAULT_THEMES)
.build(app)?;
let commit_push = MenuItemBuilder::new("Commit & Push")
.id(VAULT_COMMIT_PUSH)
.build(app)?;
let resolve_conflicts = MenuItemBuilder::new("Resolve Conflicts")
.id(VAULT_RESOLVE_CONFLICTS)
.enabled(false)
.build(app)?;
let view_changes = MenuItemBuilder::new("View Pending Changes")
.id(VAULT_VIEW_CHANGES)
.build(app)?;
let install_mcp = MenuItemBuilder::new("Install MCP Server")
.id(VAULT_INSTALL_MCP)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Vault")
.item(&open_vault)
.item(&remove_vault)
.item(&restore_getting_started)
.separator()
.item(&command_palette)
.item(&new_theme)
.item(&restore_default_themes)
.separator()
.item(&commit_push)
.item(&resolve_conflicts)
.item(&view_changes)
.separator()
.item(&install_mcp)
.build()?)
}
@@ -216,6 +369,9 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
let file_menu = build_file_menu(app)?;
let edit_menu = build_edit_menu(app)?;
let view_menu = build_view_menu(app)?;
let go_menu = build_go_menu(app)?;
let note_menu = build_note_menu(app)?;
let vault_menu = build_vault_menu(app)?;
let window_menu = build_window_menu(app)?;
let menu = MenuBuilder::new(app)
@@ -223,6 +379,9 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
.item(&file_menu)
.item(&edit_menu)
.item(&view_menu)
.item(&go_menu)
.item(&note_menu)
.item(&vault_menu)
.item(&window_menu)
.build()?;
@@ -238,45 +397,78 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
/// Enable or disable menu items that depend on having an active note tab.
pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) {
let Some(menu) = app_handle.menu() else {
return;
};
for id in NOTE_DEPENDENT_IDS {
for id in ids {
if let Some(MenuItemKind::MenuItem(mi)) = menu.get(*id) {
let _ = mi.set_enabled(enabled);
}
}
}
/// Enable or disable menu items that depend on having an active note tab.
pub fn set_note_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, NOTE_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on having uncommitted changes.
pub fn set_git_commit_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_COMMIT_DEPENDENT_IDS, enabled);
}
/// Enable or disable menu items that depend on having merge conflicts.
pub fn set_git_conflict_items_enabled(app_handle: &AppHandle, enabled: bool) {
set_items_enabled(app_handle, GIT_CONFLICT_DEPENDENT_IDS, enabled);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_ids_include_all_expected_items() {
fn custom_ids_include_all_constants() {
let expected = [
"app-settings",
"file-new-note",
"file-daily-note",
"file-quick-open",
"file-save",
"file-close-tab",
"note-archive",
"note-trash",
"edit-find-in-vault",
"view-editor-only",
"view-editor-list",
"view-all",
"view-toggle-inspector",
"view-command-palette",
"view-zoom-in",
"view-zoom-out",
"view-zoom-reset",
"view-go-back",
"view-go-forward",
"app-check-for-updates",
APP_SETTINGS,
APP_CHECK_FOR_UPDATES,
FILE_NEW_NOTE,
FILE_NEW_TYPE,
FILE_DAILY_NOTE,
FILE_QUICK_OPEN,
FILE_SAVE,
FILE_CLOSE_TAB,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_RAW_EDITOR,
EDIT_TOGGLE_DIFF,
VIEW_EDITOR_ONLY,
VIEW_EDITOR_LIST,
VIEW_ALL,
VIEW_TOGGLE_PROPERTIES,
VIEW_TOGGLE_AI_CHAT,
VIEW_TOGGLE_BACKLINKS,
VIEW_COMMAND_PALETTE,
VIEW_ZOOM_IN,
VIEW_ZOOM_OUT,
VIEW_ZOOM_RESET,
VIEW_GO_BACK,
VIEW_GO_FORWARD,
GO_ALL_NOTES,
GO_FAVORITES,
GO_ARCHIVED,
GO_TRASH,
GO_CHANGES,
NOTE_ARCHIVE,
NOTE_TRASH,
VAULT_OPEN,
VAULT_REMOVE,
VAULT_RESTORE_GETTING_STARTED,
VAULT_NEW_THEME,
VAULT_RESTORE_DEFAULT_THEMES,
VAULT_COMMIT_PUSH,
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
@@ -292,4 +484,28 @@ mod tests {
);
}
}
#[test]
fn git_dependent_ids_are_subset_of_custom_ids() {
for id in GIT_COMMIT_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"git-commit-dependent ID {id} not in CUSTOM_IDS"
);
}
for id in GIT_CONFLICT_DEPENDENT_IDS {
assert!(
CUSTOM_IDS.contains(id),
"git-conflict-dependent ID {id} not in CUSTOM_IDS"
);
}
}
#[test]
fn no_duplicate_custom_ids() {
let mut seen = std::collections::HashSet::new();
for id in CUSTOM_IDS {
assert!(seen.insert(id), "duplicate custom ID: {id}");
}
}
}

View File

@@ -2,7 +2,6 @@ use crate::indexing;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
use std::time::Instant;
@@ -84,12 +83,12 @@ fn detect_collection_name(vault_path: &str) -> String {
}
fn detect_collection_name_uncached(vault_path: &str) -> String {
let qmd_bin = match indexing::find_qmd_binary() {
let qmd = match indexing::find_qmd_binary() {
Some(b) => b,
None => return "laputa".to_string(),
};
let output = Command::new(&qmd_bin).args(["collection", "list"]).output();
let output = qmd.command().args(["collection", "list"]).output();
match output {
Ok(o) if o.status.success() => {
@@ -125,8 +124,7 @@ pub fn search_vault(
) -> Result<SearchResponse, String> {
let start = Instant::now();
let qmd_bin = indexing::find_qmd_binary()
.ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?;
let qmd = indexing::find_qmd_binary().ok_or_else(|| "qmd binary not found".to_string())?;
let collection = detect_collection_name(vault_path);
@@ -137,7 +135,8 @@ pub fn search_vault(
};
let limit_str = limit.to_string();
let output = Command::new(&qmd_bin)
let output = qmd
.command()
.args([
search_cmd,
query,

View File

@@ -1,859 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
/// A theme file parsed from _themes/*.json in the vault.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeFile {
/// Filename stem (e.g. "default" for _themes/default.json)
#[serde(default)]
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub colors: HashMap<String, String>,
#[serde(default)]
pub typography: HashMap<String, String>,
#[serde(default)]
pub spacing: HashMap<String, String>,
}
/// Vault-level settings stored in .laputa/settings.json (git-tracked).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VaultSettings {
#[serde(default)]
pub theme: Option<String>,
}
/// List all theme files in _themes/ directory of the vault.
/// Seeds built-in themes if the directory is missing.
pub fn list_themes(vault_path: &str) -> Result<Vec<ThemeFile>, String> {
let themes_dir = Path::new(vault_path).join("_themes");
if !themes_dir.is_dir() {
seed_default_themes(vault_path);
}
if !themes_dir.is_dir() {
return Ok(Vec::new());
}
let mut themes = Vec::new();
let entries =
fs::read_dir(&themes_dir).map_err(|e| format!("Failed to read _themes directory: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match parse_theme_file(&path) {
Ok(theme) => themes.push(theme),
Err(e) => log::warn!("Skipping theme file {}: {e}", path.display()),
}
}
themes.sort_by(|a, b| a.name.cmp(&b.name));
Ok(themes)
}
/// Parse a single theme JSON file.
fn parse_theme_file(path: &Path) -> Result<ThemeFile, String> {
let id = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| "Invalid theme filename".to_string())?;
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
let mut theme: ThemeFile = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
theme.id = id;
Ok(theme)
}
/// Read vault-level settings from .laputa/settings.json.
pub fn get_vault_settings(vault_path: &str) -> Result<VaultSettings, String> {
let settings_path = Path::new(vault_path).join(".laputa").join("settings.json");
if !settings_path.exists() {
return Ok(VaultSettings::default());
}
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read vault settings: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault settings: {e}"))
}
/// Save vault-level settings to .laputa/settings.json.
pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<(), String> {
let laputa_dir = Path::new(vault_path).join(".laputa");
fs::create_dir_all(&laputa_dir)
.map_err(|e| format!("Failed to create .laputa directory: {e}"))?;
let json = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize vault settings: {e}"))?;
fs::write(laputa_dir.join("settings.json"), json)
.map_err(|e| format!("Failed to write vault settings: {e}"))
}
/// Set the active theme in vault settings. Pass `None` to clear.
pub fn set_active_theme(vault_path: &str, theme_id: Option<&str>) -> Result<(), String> {
let mut settings = get_vault_settings(vault_path)?;
settings.theme = theme_id.map(|s| s.to_string());
save_vault_settings(vault_path, settings)
}
/// Read a single theme file by ID from the vault's _themes/ directory.
pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String> {
let path = Path::new(vault_path)
.join("_themes")
.join(format!("{theme_id}.json"));
if !path.exists() {
return Err(format!("Theme not found: {theme_id}"));
}
parse_theme_file(&path)
}
/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet.
fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) {
if dir.is_dir() {
return;
}
if fs::create_dir_all(dir).is_err() {
return;
}
for (name, content) in files {
let _ = fs::write(dir.join(name), content);
}
log::info!("{log_msg}");
}
/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet.
/// Safe to call multiple times — only writes files that are missing.
pub fn seed_default_themes(vault_path: &str) {
seed_dir_with_files(
&Path::new(vault_path).join("_themes"),
&[
("default.json", DEFAULT_THEME),
("dark.json", DARK_THEME),
("minimal.json", MINIMAL_THEME),
],
"Seeded _themes/ with built-in themes",
);
}
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
/// Safe to call multiple times — only writes files that are missing.
pub fn seed_vault_themes(vault_path: &str) {
seed_dir_with_files(
&Path::new(vault_path).join("theme"),
&[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
],
"Seeded theme/ with built-in vault themes",
);
}
/// Create a new vault theme note in `theme/` directory.
/// Returns the absolute path to the newly created theme note.
pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result<String, String> {
let theme_dir = Path::new(vault_path).join("theme");
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
let display_name = name.unwrap_or("Untitled Theme");
let slug = slugify(display_name);
let filename = format!("{}.md", find_available_stem(&theme_dir, &slug, "md"));
let path = theme_dir.join(&filename);
let content = vault_theme_note_content(display_name, &DEFAULT_VAULT_THEME_VARS);
fs::write(&path, content).map_err(|e| format!("Failed to write theme note: {e}"))?;
Ok(path.to_string_lossy().to_string())
}
/// Convert a display name to a URL-safe slug.
fn slugify(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Find an available filename stem (base, base-2, base-3, …) that doesn't conflict when `ext` is appended.
fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String {
if !dir.join(format!("{base}.{ext}")).exists() {
return base.to_string();
}
for i in 2.. {
let candidate = format!("{base}-{i}");
if !dir.join(format!("{candidate}.{ext}")).exists() {
return candidate;
}
}
unreachable!()
}
/// Build a vault theme note markdown string from a name and CSS variable map.
fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String {
let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n");
for (key, value) in vars {
// Values with '#' or spaces need quoting; others can be bare strings.
if value.contains('#') || value.contains('\'') || value.contains(',') {
fm.push_str(&format!("{key}: \"{value}\"\n"));
} else {
fm.push_str(&format!("{key}: {value}\n"));
}
}
fm.push_str("---\n\n");
fm.push_str(&format!(
"# {name} Theme\n\nA custom {name} theme for Laputa.\n"
));
fm
}
/// Create a new theme file by copying the active theme (or default).
/// Returns the ID of the new theme.
pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String, String> {
let themes_dir = Path::new(vault_path).join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
let new_id = find_available_stem(&themes_dir, "untitled", "json");
let source = source_id.unwrap_or("default");
let source_path = themes_dir.join(format!("{source}.json"));
let content = if source_path.exists() {
let mut theme: serde_json::Value = serde_json::from_str(
&fs::read_to_string(&source_path)
.map_err(|e| format!("Failed to read source theme: {e}"))?,
)
.map_err(|e| format!("Failed to parse source theme: {e}"))?;
if let Some(obj) = theme.as_object_mut() {
obj.insert(
"name".to_string(),
serde_json::Value::String("Untitled Theme".to_string()),
);
}
serde_json::to_string_pretty(&theme)
.map_err(|e| format!("Failed to serialize new theme: {e}"))?
} else {
default_theme_json("Untitled Theme")
};
fs::write(themes_dir.join(format!("{new_id}.json")), content)
.map_err(|e| format!("Failed to write new theme: {e}"))?;
Ok(new_id)
}
/// Generate the default light theme JSON.
fn default_theme_json(name: &str) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"name": name,
"description": "Custom theme",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"sidebar-background": "#F7F6F3",
"accent": "#155DFF",
"muted": "#787774",
"border": "#E9E9E7"
},
"typography": {
"font-family": "system-ui",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "240px"
}
}))
.unwrap()
}
/// Content for the built-in default (light) theme.
pub const DEFAULT_THEME: &str = r##"{
"name": "Default",
"description": "Light theme with warm, paper-like tones",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"destructive": "#E03E3E",
"border": "#E9E9E7",
"input": "#E9E9E7",
"ring": "#155DFF",
"sidebar-background": "#F7F6F3",
"sidebar-foreground": "#37352F",
"sidebar-border": "#E9E9E7",
"sidebar-accent": "#EBEBEA"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in dark theme.
pub const DARK_THEME: &str = r##"{
"name": "Dark",
"description": "Dark variant with deep navy tones",
"colors": {
"background": "#0f0f1a",
"foreground": "#e0e0e0",
"card": "#16162a",
"popover": "#1e1e3a",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#2a2a4a",
"secondary-foreground": "#e0e0e0",
"muted": "#1e1e3a",
"muted-foreground": "#888888",
"accent": "#2a2a4a",
"accent-foreground": "#e0e0e0",
"destructive": "#f44336",
"border": "#2a2a4a",
"input": "#2a2a4a",
"ring": "#155DFF",
"sidebar-background": "#1a1a2e",
"sidebar-foreground": "#e0e0e0",
"sidebar-border": "#2a2a4a",
"sidebar-accent": "#2a2a4a"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in minimal theme.
pub const MINIMAL_THEME: &str = r##"{
"name": "Minimal",
"description": "High contrast, minimal chrome",
"colors": {
"background": "#FAFAFA",
"foreground": "#111111",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#000000",
"primary-foreground": "#FFFFFF",
"secondary": "#F0F0F0",
"secondary-foreground": "#111111",
"muted": "#F5F5F5",
"muted-foreground": "#666666",
"accent": "#F0F0F0",
"accent-foreground": "#111111",
"destructive": "#CC0000",
"border": "#E0E0E0",
"input": "#E0E0E0",
"ring": "#000000",
"sidebar-background": "#F5F5F5",
"sidebar-foreground": "#111111",
"sidebar-border": "#E0E0E0",
"sidebar-accent": "#E8E8E8"
},
"typography": {
"font-family": "'SF Mono', 'Menlo', monospace",
"font-size-base": "13px"
},
"spacing": {
"sidebar-width": "220px"
}
}"##;
/// CSS variable key-value pairs for the default light vault theme.
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
// shadcn/ui base
("background", "#FFFFFF"),
("foreground", "#37352F"),
("card", "#FFFFFF"),
("popover", "#FFFFFF"),
("primary", "#155DFF"),
("primary-foreground", "#FFFFFF"),
("secondary", "#EBEBEA"),
("secondary-foreground", "#37352F"),
("muted", "#F0F0EF"),
("muted-foreground", "#787774"),
("accent", "#EBEBEA"),
("accent-foreground", "#37352F"),
("destructive", "#E03E3E"),
("border", "#E9E9E7"),
("input", "#E9E9E7"),
("ring", "#155DFF"),
("sidebar", "#F7F6F3"),
("sidebar-foreground", "#37352F"),
("sidebar-border", "#E9E9E7"),
("sidebar-accent", "#EBEBEA"),
// Text hierarchy
("text-primary", "#37352F"),
("text-secondary", "#787774"),
("text-muted", "#B4B4B4"),
("text-heading", "#37352F"),
// Backgrounds
("bg-primary", "#FFFFFF"),
("bg-sidebar", "#F7F6F3"),
("bg-hover", "#EBEBEA"),
("bg-hover-subtle", "#F0F0EF"),
("bg-selected", "#E8F4FE"),
("border-primary", "#E9E9E7"),
// Accent colours
("accent-blue", "#155DFF"),
("accent-green", "#00B38B"),
("accent-orange", "#D9730D"),
("accent-red", "#E03E3E"),
("accent-purple", "#A932FF"),
("accent-yellow", "#F0B100"),
("accent-blue-light", "#155DFF14"),
("accent-green-light", "#00B38B14"),
("accent-purple-light", "#A932FF14"),
("accent-red-light", "#E03E3E14"),
("accent-yellow-light", "#F0B10014"),
// Typography
(
"font-family",
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
),
("font-size-base", "14px"),
// Editor
("editor-font-size", "16"),
("editor-line-height", "1.5"),
("editor-max-width", "720"),
];
/// Vault-based theme note for the built-in Default theme.
pub const DEFAULT_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: Light theme with warm, paper-like tones\n\
background: \"#FFFFFF\"\n\
foreground: \"#37352F\"\n\
card: \"#FFFFFF\"\n\
popover: \"#FFFFFF\"\n\
primary: \"#155DFF\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#EBEBEA\"\n\
secondary-foreground: \"#37352F\"\n\
muted: \"#F0F0EF\"\n\
muted-foreground: \"#787774\"\n\
accent: \"#EBEBEA\"\n\
accent-foreground: \"#37352F\"\n\
destructive: \"#E03E3E\"\n\
border: \"#E9E9E7\"\n\
input: \"#E9E9E7\"\n\
ring: \"#155DFF\"\n\
sidebar: \"#F7F6F3\"\n\
sidebar-foreground: \"#37352F\"\n\
sidebar-border: \"#E9E9E7\"\n\
sidebar-accent: \"#EBEBEA\"\n\
text-primary: \"#37352F\"\n\
text-secondary: \"#787774\"\n\
text-muted: \"#B4B4B4\"\n\
text-heading: \"#37352F\"\n\
bg-primary: \"#FFFFFF\"\n\
bg-sidebar: \"#F7F6F3\"\n\
bg-hover: \"#EBEBEA\"\n\
bg-hover-subtle: \"#F0F0EF\"\n\
bg-selected: \"#E8F4FE\"\n\
border-primary: \"#E9E9E7\"\n\
accent-blue: \"#155DFF\"\n\
accent-green: \"#00B38B\"\n\
accent-orange: \"#D9730D\"\n\
accent-red: \"#E03E3E\"\n\
accent-purple: \"#A932FF\"\n\
accent-yellow: \"#F0B100\"\n\
accent-blue-light: \"#155DFF14\"\n\
accent-green-light: \"#00B38B14\"\n\
accent-purple-light: \"#A932FF14\"\n\
accent-red-light: \"#E03E3E14\"\n\
accent-yellow-light: \"#F0B10014\"\n\
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
font-size-base: 14px\n\
editor-font-size: 16\n\
editor-line-height: 1.5\n\
editor-max-width: 720\n\
---\n\
\n\
# Default Theme\n\
\n\
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
/// Vault-based theme note for the built-in Dark theme.
pub const DARK_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: Dark variant with deep navy tones\n\
background: \"#0f0f1a\"\n\
foreground: \"#e0e0e0\"\n\
card: \"#16162a\"\n\
popover: \"#1e1e3a\"\n\
primary: \"#155DFF\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#2a2a4a\"\n\
secondary-foreground: \"#e0e0e0\"\n\
muted: \"#1e1e3a\"\n\
muted-foreground: \"#888888\"\n\
accent: \"#2a2a4a\"\n\
accent-foreground: \"#e0e0e0\"\n\
destructive: \"#f44336\"\n\
border: \"#2a2a4a\"\n\
input: \"#2a2a4a\"\n\
ring: \"#155DFF\"\n\
sidebar: \"#1a1a2e\"\n\
sidebar-foreground: \"#e0e0e0\"\n\
sidebar-border: \"#2a2a4a\"\n\
sidebar-accent: \"#2a2a4a\"\n\
text-primary: \"#e0e0e0\"\n\
text-secondary: \"#888888\"\n\
text-muted: \"#666666\"\n\
text-heading: \"#e0e0e0\"\n\
bg-primary: \"#0f0f1a\"\n\
bg-sidebar: \"#1a1a2e\"\n\
bg-hover: \"#2a2a4a\"\n\
bg-hover-subtle: \"#1e1e3a\"\n\
bg-selected: \"#155DFF22\"\n\
border-primary: \"#2a2a4a\"\n\
accent-blue: \"#155DFF\"\n\
accent-green: \"#00B38B\"\n\
accent-orange: \"#D9730D\"\n\
accent-red: \"#f44336\"\n\
accent-purple: \"#A932FF\"\n\
accent-yellow: \"#F0B100\"\n\
accent-blue-light: \"#155DFF33\"\n\
accent-green-light: \"#00B38B33\"\n\
accent-purple-light: \"#A932FF33\"\n\
accent-red-light: \"#f4433633\"\n\
accent-yellow-light: \"#F0B10033\"\n\
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
font-size-base: 14px\n\
editor-font-size: 16\n\
editor-line-height: 1.5\n\
editor-max-width: 720\n\
---\n\
\n\
# Dark Theme\n\
\n\
A dark theme with deep navy tones for comfortable night-time reading.\n";
/// Vault-based theme note for the built-in Minimal theme.
pub const MINIMAL_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: High contrast, minimal chrome\n\
background: \"#FAFAFA\"\n\
foreground: \"#111111\"\n\
card: \"#FFFFFF\"\n\
popover: \"#FFFFFF\"\n\
primary: \"#000000\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#F0F0F0\"\n\
secondary-foreground: \"#111111\"\n\
muted: \"#F5F5F5\"\n\
muted-foreground: \"#666666\"\n\
accent: \"#F0F0F0\"\n\
accent-foreground: \"#111111\"\n\
destructive: \"#CC0000\"\n\
border: \"#E0E0E0\"\n\
input: \"#E0E0E0\"\n\
ring: \"#000000\"\n\
sidebar: \"#F5F5F5\"\n\
sidebar-foreground: \"#111111\"\n\
sidebar-border: \"#E0E0E0\"\n\
sidebar-accent: \"#E8E8E8\"\n\
text-primary: \"#111111\"\n\
text-secondary: \"#666666\"\n\
text-muted: \"#999999\"\n\
text-heading: \"#111111\"\n\
bg-primary: \"#FAFAFA\"\n\
bg-sidebar: \"#F5F5F5\"\n\
bg-hover: \"#EBEBEB\"\n\
bg-hover-subtle: \"#F5F5F5\"\n\
bg-selected: \"#00000014\"\n\
border-primary: \"#E0E0E0\"\n\
accent-blue: \"#000000\"\n\
accent-green: \"#006600\"\n\
accent-orange: \"#996600\"\n\
accent-red: \"#CC0000\"\n\
accent-purple: \"#660099\"\n\
accent-yellow: \"#996600\"\n\
accent-blue-light: \"#00000014\"\n\
accent-green-light: \"#00660014\"\n\
accent-purple-light: \"#66009914\"\n\
accent-red-light: \"#CC000014\"\n\
accent-yellow-light: \"#99660014\"\n\
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
font-size-base: 13px\n\
editor-font-size: 15\n\
editor-line-height: 1.6\n\
editor-max-width: 680\n\
---\n\
\n\
# Minimal Theme\n\
\n\
High contrast, minimal chrome. Monospace typography throughout.\n";
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_vault_with_themes(dir: &TempDir) -> String {
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
vault.to_string_lossy().to_string()
}
#[test]
fn test_list_themes_returns_sorted_list() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
assert_eq!(themes[0].id, "dark");
assert_eq!(themes[1].id, "default");
}
#[test]
fn test_list_themes_seeds_defaults_when_no_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("empty-vault");
fs::create_dir_all(&vault).unwrap();
let themes = list_themes(vault.to_str().unwrap()).unwrap();
assert_eq!(themes.len(), 3);
let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"Default"));
assert!(names.contains(&"Dark"));
assert!(names.contains(&"Minimal"));
}
#[test]
fn test_get_theme_by_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let theme = get_theme(&vault, "default").unwrap();
assert_eq!(theme.name, "Default");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_get_theme_not_found() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let result = get_theme(&vault, "nonexistent");
assert!(result.is_err());
}
#[test]
fn test_vault_settings_roundtrip() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
// Default settings have no theme
let settings = get_vault_settings(vp).unwrap();
assert!(settings.theme.is_none());
// Set and read back
set_active_theme(vp, Some("dark")).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme.as_deref(), Some("dark"));
// Clear theme
set_active_theme(vp, None).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme, None);
}
#[test]
fn test_vault_settings_creates_laputa_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
assert!(!vault.join(".laputa").exists());
save_vault_settings(
vp,
VaultSettings {
theme: Some("light".into()),
},
)
.unwrap();
assert!(vault.join(".laputa").join("settings.json").exists());
}
#[test]
fn test_create_theme_copies_source() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let new_id = create_theme(&vault, Some("default")).unwrap();
assert_eq!(new_id, "untitled");
let theme = get_theme(&vault, &new_id).unwrap();
assert_eq!(theme.name, "Untitled Theme");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_create_theme_increments_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let id1 = create_theme(&vault, None).unwrap();
assert_eq!(id1, "untitled");
let id2 = create_theme(&vault, None).unwrap();
assert_eq!(id2, "untitled-2");
}
#[test]
fn test_parse_all_builtin_themes() {
for (name, content) in [
("default", DEFAULT_THEME),
("dark", DARK_THEME),
("minimal", MINIMAL_THEME),
] {
let theme: ThemeFile = serde_json::from_str(content)
.unwrap_or_else(|e| panic!("Failed to parse {name} theme: {e}"));
assert!(!theme.name.is_empty(), "{name} theme should have a name");
assert!(!theme.colors.is_empty(), "{name} theme should have colors");
}
}
#[test]
fn test_list_themes_ignores_non_json_files() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("readme.txt"), "not a theme").unwrap();
fs::write(themes_dir.join(".DS_Store"), "").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2); // only default and dark
}
#[test]
fn test_list_themes_skips_malformed_json() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("broken.json"), "not valid json{{{").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2); // broken.json is skipped
}
#[test]
fn test_seed_vault_themes_creates_theme_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
assert!(!vault.join("theme").exists());
seed_vault_themes(vp);
assert!(vault.join("theme").is_dir());
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
}
#[test]
fn test_seed_vault_themes_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
seed_vault_themes(vp); // second call should be a no-op
assert!(vault.join("theme").join("default.md").exists());
}
#[test]
fn test_create_vault_theme_creates_md_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, Some("My Theme")).unwrap();
assert!(std::path::Path::new(&path).exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Is A: Theme"));
assert!(content.contains("# My Theme"));
assert!(content.contains("background:"));
}
#[test]
fn test_create_vault_theme_default_name() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, None).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# Untitled Theme"));
}
#[test]
fn test_create_vault_theme_avoids_conflicts() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let p1 = create_vault_theme(vp, Some("Custom")).unwrap();
let p2 = create_vault_theme(vp, Some("Custom")).unwrap();
assert_ne!(p1, p2);
}
#[test]
fn test_slugify() {
assert_eq!(slugify("My Cool Theme"), "my-cool-theme");
assert_eq!(slugify("default"), "default");
assert_eq!(slugify("Dark Mode!"), "dark-mode");
}
#[test]
fn test_vault_theme_content_contains_all_vars() {
let content = DEFAULT_VAULT_THEME;
assert!(content.contains("background:"));
assert!(content.contains("primary:"));
assert!(content.contains("sidebar:"));
assert!(content.contains("text-primary:"));
assert!(content.contains("accent-blue:"));
assert!(content.contains("editor-font-size:"));
}
}

View File

@@ -0,0 +1,219 @@
use std::fs;
use std::path::Path;
use super::defaults::DEFAULT_VAULT_THEME_VARS;
/// Create a new vault theme note in `theme/` directory.
/// Returns the absolute path to the newly created theme note.
pub fn create_vault_theme(vault_path: &str, name: Option<&str>) -> Result<String, String> {
let theme_dir = Path::new(vault_path).join("theme");
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
let display_name = name.unwrap_or("Untitled Theme");
let slug = slugify(display_name);
let filename = format!("{}.md", find_available_stem(&theme_dir, &slug, "md"));
let path = theme_dir.join(&filename);
let content = vault_theme_note_content(display_name, &DEFAULT_VAULT_THEME_VARS);
fs::write(&path, content).map_err(|e| format!("Failed to write theme note: {e}"))?;
Ok(path.to_string_lossy().to_string())
}
/// Create a new theme file by copying the active theme (or default).
/// Returns the ID of the new theme.
pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String, String> {
let themes_dir = Path::new(vault_path).join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
let new_id = find_available_stem(&themes_dir, "untitled", "json");
let source = source_id.unwrap_or("default");
let source_path = themes_dir.join(format!("{source}.json"));
let content = if source_path.exists() {
let mut theme: serde_json::Value = serde_json::from_str(
&fs::read_to_string(&source_path)
.map_err(|e| format!("Failed to read source theme: {e}"))?,
)
.map_err(|e| format!("Failed to parse source theme: {e}"))?;
if let Some(obj) = theme.as_object_mut() {
obj.insert(
"name".to_string(),
serde_json::Value::String("Untitled Theme".to_string()),
);
}
serde_json::to_string_pretty(&theme)
.map_err(|e| format!("Failed to serialize new theme: {e}"))?
} else {
default_theme_json("Untitled Theme")
};
fs::write(themes_dir.join(format!("{new_id}.json")), content)
.map_err(|e| format!("Failed to write new theme: {e}"))?;
Ok(new_id)
}
/// Convert a display name to a URL-safe slug.
fn slugify(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Find an available filename stem (base, base-2, base-3, …) that doesn't
/// conflict when `ext` is appended.
fn find_available_stem(dir: &Path, base: &str, ext: &str) -> String {
if !dir.join(format!("{base}.{ext}")).exists() {
return base.to_string();
}
for i in 2.. {
let candidate = format!("{base}-{i}");
if !dir.join(format!("{candidate}.{ext}")).exists() {
return candidate;
}
}
unreachable!()
}
/// Build a vault theme note markdown string from a name and CSS variable map.
fn vault_theme_note_content(name: &str, vars: &[(&str, &str)]) -> String {
let mut fm = format!("---\nIs A: Theme\nDescription: {name} theme\n");
for (key, value) in vars {
if value.contains('#') || value.contains('\'') || value.contains(',') {
fm.push_str(&format!("{key}: \"{value}\"\n"));
} else {
fm.push_str(&format!("{key}: {value}\n"));
}
}
fm.push_str("---\n\n");
fm.push_str(&format!(
"# {name} Theme\n\nA custom {name} theme for Laputa.\n"
));
fm
}
/// Generate the default light theme JSON.
fn default_theme_json(name: &str) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"name": name,
"description": "Custom theme",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"sidebar-background": "#F7F6F3",
"accent": "#155DFF",
"muted": "#787774",
"border": "#E9E9E7"
},
"typography": {
"font-family": "system-ui",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "240px"
}
}))
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::theme::defaults::*;
use crate::theme::get_theme;
use std::fs;
use tempfile::TempDir;
fn setup_vault_with_themes(dir: &TempDir) -> String {
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
vault.to_string_lossy().to_string()
}
#[test]
fn test_create_theme_copies_source() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let new_id = create_theme(&vault, Some("default")).unwrap();
assert_eq!(new_id, "untitled");
let theme = get_theme(&vault, &new_id).unwrap();
assert_eq!(theme.name, "Untitled Theme");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_create_theme_increments_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let id1 = create_theme(&vault, None).unwrap();
assert_eq!(id1, "untitled");
let id2 = create_theme(&vault, None).unwrap();
assert_eq!(id2, "untitled-2");
}
#[test]
fn test_create_vault_theme_creates_md_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, Some("My Theme")).unwrap();
assert!(std::path::Path::new(&path).exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Is A: Theme"));
assert!(content.contains("# My Theme"));
assert!(content.contains("background:"));
}
#[test]
fn test_create_vault_theme_default_name() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let path = create_vault_theme(vp, None).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("# Untitled Theme"));
}
#[test]
fn test_create_vault_theme_avoids_conflicts() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let p1 = create_vault_theme(vp, Some("Custom")).unwrap();
let p2 = create_vault_theme(vp, Some("Custom")).unwrap();
assert_ne!(p1, p2);
}
#[test]
fn test_slugify() {
assert_eq!(slugify("My Cool Theme"), "my-cool-theme");
assert_eq!(slugify("default"), "default");
assert_eq!(slugify("Dark Mode!"), "dark-mode");
}
}

View File

@@ -0,0 +1,343 @@
/// Content for the built-in default (light) theme.
pub const DEFAULT_THEME: &str = r##"{
"name": "Default",
"description": "Light theme with warm, paper-like tones",
"colors": {
"background": "#FFFFFF",
"foreground": "#37352F",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#EBEBEA",
"secondary-foreground": "#37352F",
"muted": "#F0F0EF",
"muted-foreground": "#787774",
"accent": "#EBEBEA",
"accent-foreground": "#37352F",
"destructive": "#E03E3E",
"border": "#E9E9E7",
"input": "#E9E9E7",
"ring": "#155DFF",
"sidebar-background": "#F7F6F3",
"sidebar-foreground": "#37352F",
"sidebar-border": "#E9E9E7",
"sidebar-accent": "#EBEBEA"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in dark theme.
pub const DARK_THEME: &str = r##"{
"name": "Dark",
"description": "Dark variant with deep navy tones",
"colors": {
"background": "#0f0f1a",
"foreground": "#e0e0e0",
"card": "#16162a",
"popover": "#1e1e3a",
"primary": "#155DFF",
"primary-foreground": "#FFFFFF",
"secondary": "#2a2a4a",
"secondary-foreground": "#e0e0e0",
"muted": "#1e1e3a",
"muted-foreground": "#888888",
"accent": "#2a2a4a",
"accent-foreground": "#e0e0e0",
"destructive": "#f44336",
"border": "#2a2a4a",
"input": "#2a2a4a",
"ring": "#155DFF",
"sidebar-background": "#1a1a2e",
"sidebar-foreground": "#e0e0e0",
"sidebar-border": "#2a2a4a",
"sidebar-accent": "#2a2a4a"
},
"typography": {
"font-family": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"font-size-base": "14px"
},
"spacing": {
"sidebar-width": "250px"
}
}"##;
/// Content for the built-in minimal theme.
pub const MINIMAL_THEME: &str = r##"{
"name": "Minimal",
"description": "High contrast, minimal chrome",
"colors": {
"background": "#FAFAFA",
"foreground": "#111111",
"card": "#FFFFFF",
"popover": "#FFFFFF",
"primary": "#000000",
"primary-foreground": "#FFFFFF",
"secondary": "#F0F0F0",
"secondary-foreground": "#111111",
"muted": "#F5F5F5",
"muted-foreground": "#666666",
"accent": "#F0F0F0",
"accent-foreground": "#111111",
"destructive": "#CC0000",
"border": "#E0E0E0",
"input": "#E0E0E0",
"ring": "#000000",
"sidebar-background": "#F5F5F5",
"sidebar-foreground": "#111111",
"sidebar-border": "#E0E0E0",
"sidebar-accent": "#E8E8E8"
},
"typography": {
"font-family": "'SF Mono', 'Menlo', monospace",
"font-size-base": "13px"
},
"spacing": {
"sidebar-width": "220px"
}
}"##;
/// CSS variable key-value pairs for the default light vault theme.
pub const DEFAULT_VAULT_THEME_VARS: [(&str, &str); 46] = [
// shadcn/ui base
("background", "#FFFFFF"),
("foreground", "#37352F"),
("card", "#FFFFFF"),
("popover", "#FFFFFF"),
("primary", "#155DFF"),
("primary-foreground", "#FFFFFF"),
("secondary", "#EBEBEA"),
("secondary-foreground", "#37352F"),
("muted", "#F0F0EF"),
("muted-foreground", "#787774"),
("accent", "#EBEBEA"),
("accent-foreground", "#37352F"),
("destructive", "#E03E3E"),
("border", "#E9E9E7"),
("input", "#E9E9E7"),
("ring", "#155DFF"),
("sidebar", "#F7F6F3"),
("sidebar-foreground", "#37352F"),
("sidebar-border", "#E9E9E7"),
("sidebar-accent", "#EBEBEA"),
// Text hierarchy
("text-primary", "#37352F"),
("text-secondary", "#787774"),
("text-muted", "#B4B4B4"),
("text-heading", "#37352F"),
// Backgrounds
("bg-primary", "#FFFFFF"),
("bg-sidebar", "#F7F6F3"),
("bg-hover", "#EBEBEA"),
("bg-hover-subtle", "#F0F0EF"),
("bg-selected", "#E8F4FE"),
("border-primary", "#E9E9E7"),
// Accent colours
("accent-blue", "#155DFF"),
("accent-green", "#00B38B"),
("accent-orange", "#D9730D"),
("accent-red", "#E03E3E"),
("accent-purple", "#A932FF"),
("accent-yellow", "#F0B100"),
("accent-blue-light", "#155DFF14"),
("accent-green-light", "#00B38B14"),
("accent-purple-light", "#A932FF14"),
("accent-red-light", "#E03E3E14"),
("accent-yellow-light", "#F0B10014"),
// Typography
(
"font-family",
"'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
),
("font-size-base", "14px"),
// Editor
("editor-font-size", "16"),
("editor-line-height", "1.5"),
("editor-max-width", "720"),
];
/// Vault-based theme note for the built-in Default theme.
pub const DEFAULT_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: Light theme with warm, paper-like tones\n\
background: \"#FFFFFF\"\n\
foreground: \"#37352F\"\n\
card: \"#FFFFFF\"\n\
popover: \"#FFFFFF\"\n\
primary: \"#155DFF\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#EBEBEA\"\n\
secondary-foreground: \"#37352F\"\n\
muted: \"#F0F0EF\"\n\
muted-foreground: \"#787774\"\n\
accent: \"#EBEBEA\"\n\
accent-foreground: \"#37352F\"\n\
destructive: \"#E03E3E\"\n\
border: \"#E9E9E7\"\n\
input: \"#E9E9E7\"\n\
ring: \"#155DFF\"\n\
sidebar: \"#F7F6F3\"\n\
sidebar-foreground: \"#37352F\"\n\
sidebar-border: \"#E9E9E7\"\n\
sidebar-accent: \"#EBEBEA\"\n\
text-primary: \"#37352F\"\n\
text-secondary: \"#787774\"\n\
text-muted: \"#B4B4B4\"\n\
text-heading: \"#37352F\"\n\
bg-primary: \"#FFFFFF\"\n\
bg-sidebar: \"#F7F6F3\"\n\
bg-hover: \"#EBEBEA\"\n\
bg-hover-subtle: \"#F0F0EF\"\n\
bg-selected: \"#E8F4FE\"\n\
border-primary: \"#E9E9E7\"\n\
accent-blue: \"#155DFF\"\n\
accent-green: \"#00B38B\"\n\
accent-orange: \"#D9730D\"\n\
accent-red: \"#E03E3E\"\n\
accent-purple: \"#A932FF\"\n\
accent-yellow: \"#F0B100\"\n\
accent-blue-light: \"#155DFF14\"\n\
accent-green-light: \"#00B38B14\"\n\
accent-purple-light: \"#A932FF14\"\n\
accent-red-light: \"#E03E3E14\"\n\
accent-yellow-light: \"#F0B10014\"\n\
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
font-size-base: 14px\n\
editor-font-size: 16\n\
editor-line-height: 1.5\n\
editor-max-width: 720\n\
---\n\
\n\
# Default Theme\n\
\n\
The default light theme for Laputa. Clean and warm, inspired by Notion.\n";
/// Vault-based theme note for the built-in Dark theme.
pub const DARK_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: Dark variant with deep navy tones\n\
background: \"#0f0f1a\"\n\
foreground: \"#e0e0e0\"\n\
card: \"#16162a\"\n\
popover: \"#1e1e3a\"\n\
primary: \"#155DFF\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#2a2a4a\"\n\
secondary-foreground: \"#e0e0e0\"\n\
muted: \"#1e1e3a\"\n\
muted-foreground: \"#888888\"\n\
accent: \"#2a2a4a\"\n\
accent-foreground: \"#e0e0e0\"\n\
destructive: \"#f44336\"\n\
border: \"#2a2a4a\"\n\
input: \"#2a2a4a\"\n\
ring: \"#155DFF\"\n\
sidebar: \"#1a1a2e\"\n\
sidebar-foreground: \"#e0e0e0\"\n\
sidebar-border: \"#2a2a4a\"\n\
sidebar-accent: \"#2a2a4a\"\n\
text-primary: \"#e0e0e0\"\n\
text-secondary: \"#888888\"\n\
text-muted: \"#666666\"\n\
text-heading: \"#e0e0e0\"\n\
bg-primary: \"#0f0f1a\"\n\
bg-sidebar: \"#1a1a2e\"\n\
bg-hover: \"#2a2a4a\"\n\
bg-hover-subtle: \"#1e1e3a\"\n\
bg-selected: \"#155DFF22\"\n\
border-primary: \"#2a2a4a\"\n\
accent-blue: \"#155DFF\"\n\
accent-green: \"#00B38B\"\n\
accent-orange: \"#D9730D\"\n\
accent-red: \"#f44336\"\n\
accent-purple: \"#A932FF\"\n\
accent-yellow: \"#F0B100\"\n\
accent-blue-light: \"#155DFF33\"\n\
accent-green-light: \"#00B38B33\"\n\
accent-purple-light: \"#A932FF33\"\n\
accent-red-light: \"#f4433633\"\n\
accent-yellow-light: \"#F0B10033\"\n\
font-family: \"'Inter', -apple-system, BlinkMacSystemFont, sans-serif\"\n\
font-size-base: 14px\n\
editor-font-size: 16\n\
editor-line-height: 1.5\n\
editor-max-width: 720\n\
---\n\
\n\
# Dark Theme\n\
\n\
A dark theme with deep navy tones for comfortable night-time reading.\n";
/// Vault-based theme note for the built-in Minimal theme.
pub const MINIMAL_VAULT_THEME: &str = "---\n\
Is A: Theme\n\
Description: High contrast, minimal chrome\n\
background: \"#FAFAFA\"\n\
foreground: \"#111111\"\n\
card: \"#FFFFFF\"\n\
popover: \"#FFFFFF\"\n\
primary: \"#000000\"\n\
primary-foreground: \"#FFFFFF\"\n\
secondary: \"#F0F0F0\"\n\
secondary-foreground: \"#111111\"\n\
muted: \"#F5F5F5\"\n\
muted-foreground: \"#666666\"\n\
accent: \"#F0F0F0\"\n\
accent-foreground: \"#111111\"\n\
destructive: \"#CC0000\"\n\
border: \"#E0E0E0\"\n\
input: \"#E0E0E0\"\n\
ring: \"#000000\"\n\
sidebar: \"#F5F5F5\"\n\
sidebar-foreground: \"#111111\"\n\
sidebar-border: \"#E0E0E0\"\n\
sidebar-accent: \"#E8E8E8\"\n\
text-primary: \"#111111\"\n\
text-secondary: \"#666666\"\n\
text-muted: \"#999999\"\n\
text-heading: \"#111111\"\n\
bg-primary: \"#FAFAFA\"\n\
bg-sidebar: \"#F5F5F5\"\n\
bg-hover: \"#EBEBEB\"\n\
bg-hover-subtle: \"#F5F5F5\"\n\
bg-selected: \"#00000014\"\n\
border-primary: \"#E0E0E0\"\n\
accent-blue: \"#000000\"\n\
accent-green: \"#006600\"\n\
accent-orange: \"#996600\"\n\
accent-red: \"#CC0000\"\n\
accent-purple: \"#660099\"\n\
accent-yellow: \"#996600\"\n\
accent-blue-light: \"#00000014\"\n\
accent-green-light: \"#00660014\"\n\
accent-purple-light: \"#66009914\"\n\
accent-red-light: \"#CC000014\"\n\
accent-yellow-light: \"#99660014\"\n\
font-family: \"'SF Mono', 'Menlo', monospace\"\n\
font-size-base: 13px\n\
editor-font-size: 15\n\
editor-line-height: 1.6\n\
editor-max-width: 680\n\
---\n\
\n\
# Minimal Theme\n\
\n\
High contrast, minimal chrome. Monospace typography throughout.\n";
/// Type definition for the Theme note type.
pub const THEME_TYPE_DEFINITION: &str = "---\n\
Is A: Type\n\
icon: palette\n\
color: purple\n\
order: 50\n\
---\n\
\n\
# Theme\n\
\n\
A visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n";

269
src-tauri/src/theme/mod.rs Normal file
View File

@@ -0,0 +1,269 @@
mod create;
pub mod defaults;
mod seed;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
pub use create::{create_theme, create_vault_theme};
pub use defaults::*;
pub use seed::{
ensure_theme_type_definition, ensure_vault_themes, restore_default_themes, seed_default_themes,
seed_vault_themes,
};
/// A theme file parsed from _themes/*.json in the vault.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeFile {
/// Filename stem (e.g. "default" for _themes/default.json)
#[serde(default)]
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub colors: HashMap<String, String>,
#[serde(default)]
pub typography: HashMap<String, String>,
#[serde(default)]
pub spacing: HashMap<String, String>,
}
/// Vault-level settings stored in .laputa/settings.json (git-tracked).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VaultSettings {
#[serde(default)]
pub theme: Option<String>,
}
/// List all theme files in _themes/ directory of the vault.
/// Seeds built-in themes if the directory is missing.
pub fn list_themes(vault_path: &str) -> Result<Vec<ThemeFile>, String> {
let themes_dir = Path::new(vault_path).join("_themes");
if !themes_dir.is_dir() {
seed_default_themes(vault_path);
}
if !themes_dir.is_dir() {
return Ok(Vec::new());
}
let mut themes = Vec::new();
let entries =
fs::read_dir(&themes_dir).map_err(|e| format!("Failed to read _themes directory: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
match parse_theme_file(&path) {
Ok(theme) => themes.push(theme),
Err(e) => log::warn!("Skipping theme file {}: {e}", path.display()),
}
}
themes.sort_by(|a, b| a.name.cmp(&b.name));
Ok(themes)
}
/// Parse a single theme JSON file.
fn parse_theme_file(path: &Path) -> Result<ThemeFile, String> {
let id = path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| "Invalid theme filename".to_string())?;
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
let mut theme: ThemeFile = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
theme.id = id;
Ok(theme)
}
/// Read vault-level settings from .laputa/settings.json.
pub fn get_vault_settings(vault_path: &str) -> Result<VaultSettings, String> {
let settings_path = Path::new(vault_path).join(".laputa").join("settings.json");
if !settings_path.exists() {
return Ok(VaultSettings::default());
}
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read vault settings: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault settings: {e}"))
}
/// Save vault-level settings to .laputa/settings.json.
pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<(), String> {
let laputa_dir = Path::new(vault_path).join(".laputa");
fs::create_dir_all(&laputa_dir)
.map_err(|e| format!("Failed to create .laputa directory: {e}"))?;
let json = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize vault settings: {e}"))?;
fs::write(laputa_dir.join("settings.json"), json)
.map_err(|e| format!("Failed to write vault settings: {e}"))
}
/// Set the active theme in vault settings. Pass `None` to clear.
pub fn set_active_theme(vault_path: &str, theme_id: Option<&str>) -> Result<(), String> {
let mut settings = get_vault_settings(vault_path)?;
settings.theme = theme_id.map(|s| s.to_string());
save_vault_settings(vault_path, settings)
}
/// Read a single theme file by ID from the vault's _themes/ directory.
pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String> {
let path = Path::new(vault_path)
.join("_themes")
.join(format!("{theme_id}.json"));
if !path.exists() {
return Err(format!("Theme not found: {theme_id}"));
}
parse_theme_file(&path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_vault_with_themes(dir: &TempDir) -> String {
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
fs::create_dir_all(&themes_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(themes_dir.join("dark.json"), DARK_THEME).unwrap();
vault.to_string_lossy().to_string()
}
#[test]
fn test_list_themes_returns_sorted_list() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
assert_eq!(themes[0].id, "dark");
assert_eq!(themes[1].id, "default");
}
#[test]
fn test_list_themes_seeds_defaults_when_no_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("empty-vault");
fs::create_dir_all(&vault).unwrap();
let themes = list_themes(vault.to_str().unwrap()).unwrap();
assert_eq!(themes.len(), 3);
let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"Default"));
assert!(names.contains(&"Dark"));
assert!(names.contains(&"Minimal"));
}
#[test]
fn test_get_theme_by_id() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let theme = get_theme(&vault, "default").unwrap();
assert_eq!(theme.name, "Default");
assert!(!theme.colors.is_empty());
}
#[test]
fn test_get_theme_not_found() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let result = get_theme(&vault, "nonexistent");
assert!(result.is_err());
}
#[test]
fn test_vault_settings_roundtrip() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let settings = get_vault_settings(vp).unwrap();
assert!(settings.theme.is_none());
set_active_theme(vp, Some("dark")).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme.as_deref(), Some("dark"));
set_active_theme(vp, None).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme, None);
}
#[test]
fn test_vault_settings_creates_laputa_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
assert!(!vault.join(".laputa").exists());
save_vault_settings(
vp,
VaultSettings {
theme: Some("light".into()),
},
)
.unwrap();
assert!(vault.join(".laputa").join("settings.json").exists());
}
#[test]
fn test_parse_all_builtin_themes() {
for (name, content) in [
("default", DEFAULT_THEME),
("dark", DARK_THEME),
("minimal", MINIMAL_THEME),
] {
let theme: ThemeFile = serde_json::from_str(content)
.unwrap_or_else(|e| panic!("Failed to parse {name} theme: {e}"));
assert!(!theme.name.is_empty(), "{name} theme should have a name");
assert!(!theme.colors.is_empty(), "{name} theme should have colors");
}
}
#[test]
fn test_list_themes_ignores_non_json_files() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("readme.txt"), "not a theme").unwrap();
fs::write(themes_dir.join(".DS_Store"), "").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
}
#[test]
fn test_list_themes_skips_malformed_json() {
let dir = TempDir::new().unwrap();
let vault = setup_vault_with_themes(&dir);
let themes_dir = Path::new(&vault).join("_themes");
fs::write(themes_dir.join("broken.json"), "not valid json{{{").unwrap();
let themes = list_themes(&vault).unwrap();
assert_eq!(themes.len(), 2);
}
#[test]
fn test_vault_theme_content_contains_all_vars() {
let content = DEFAULT_VAULT_THEME;
assert!(content.contains("background:"));
assert!(content.contains("primary:"));
assert!(content.contains("sidebar:"));
assert!(content.contains("text-primary:"));
assert!(content.contains("accent-blue:"));
assert!(content.contains("editor-font-size:"));
}
}

344
src-tauri/src/theme/seed.rs Normal file
View File

@@ -0,0 +1,344 @@
use std::fs;
use std::path::Path;
use super::defaults::*;
/// Create `dir` and write each `(filename, content)` pair if the directory doesn't exist yet.
fn seed_dir_with_files(dir: &Path, files: &[(&str, &str)], log_msg: &str) {
if dir.is_dir() {
return;
}
if fs::create_dir_all(dir).is_err() {
return;
}
for (name, content) in files {
let _ = fs::write(dir.join(name), content);
}
log::info!("{log_msg}");
}
/// Seed the `_themes/` directory with built-in themes if it doesn't exist yet.
/// Safe to call multiple times — only writes files that are missing.
pub fn seed_default_themes(vault_path: &str) {
seed_dir_with_files(
&Path::new(vault_path).join("_themes"),
&[
("default.json", DEFAULT_THEME),
("dark.json", DARK_THEME),
("minimal.json", MINIMAL_THEME),
],
"Seeded _themes/ with built-in themes",
);
}
/// Seed the vault `theme/` directory with built-in vault-based theme notes.
/// Per-file idempotent: creates the directory if missing, writes each default
/// file only when it doesn't exist or is empty (corrupt). Never overwrites
/// existing files that have content.
pub fn seed_vault_themes(vault_path: &str) {
let theme_dir = Path::new(vault_path).join("theme");
if fs::create_dir_all(&theme_dir).is_err() {
return;
}
let defaults: &[(&str, &str)] = &[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
];
let mut seeded = false;
for (name, content) in defaults {
let path = theme_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
let _ = fs::write(&path, content);
seeded = true;
}
}
if seeded {
log::info!("Seeded theme/ with built-in vault themes");
}
}
/// Ensure vault theme files exist. Returns an error if the theme directory
/// cannot be created (e.g. read-only filesystem).
pub fn ensure_vault_themes(vault_path: &str) -> Result<(), String> {
let theme_dir = Path::new(vault_path).join("theme");
fs::create_dir_all(&theme_dir).map_err(|e| format!("Failed to create theme directory: {e}"))?;
let defaults: &[(&str, &str)] = &[
("default.md", DEFAULT_VAULT_THEME),
("dark.md", DARK_VAULT_THEME),
("minimal.md", MINIMAL_VAULT_THEME),
];
for (name, content) in defaults {
let path = theme_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, content).map_err(|e| format!("Failed to write theme/{name}: {e}"))?;
}
}
Ok(())
}
/// Restore default themes for a vault: seeds both `_themes/` (JSON) and
/// `theme/` (markdown notes). Per-file idempotent — never overwrites files
/// that already have content. Returns an error on read-only filesystems.
pub fn restore_default_themes(vault_path: &str) -> Result<String, String> {
// Seed _themes/ JSON files (per-file idempotent)
let themes_dir = Path::new(vault_path).join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
let json_defaults: &[(&str, &str)] = &[
("default.json", DEFAULT_THEME),
("dark.json", DARK_THEME),
("minimal.json", MINIMAL_THEME),
];
for (name, content) in json_defaults {
let path = themes_dir.join(name);
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, content)
.map_err(|e| format!("Failed to write _themes/{name}: {e}"))?;
}
}
// Seed theme/ markdown notes (reuses ensure_vault_themes for consistency)
ensure_vault_themes(vault_path)?;
// Seed type/theme.md so the Theme type has an icon and label in the sidebar
ensure_theme_type_definition(vault_path)?;
Ok("Default themes restored".to_string())
}
/// Create `type/theme.md` if it doesn't exist (gives the Theme type a sidebar icon/color).
pub fn ensure_theme_type_definition(vault_path: &str) -> Result<(), String> {
let type_dir = Path::new(vault_path).join("type");
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
let path = type_dir.join("theme.md");
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
if needs_write {
fs::write(&path, THEME_TYPE_DEFINITION)
.map_err(|e| format!("Failed to write type/theme.md: {e}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_seed_vault_themes_creates_theme_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
assert!(!vault.join("theme").exists());
seed_vault_themes(vp);
assert!(vault.join("theme").is_dir());
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
}
#[test]
fn test_seed_vault_themes_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
seed_vault_themes(vp); // second call should be a no-op
assert!(vault.join("theme").join("default.md").exists());
}
#[test]
fn test_seed_vault_themes_writes_missing_files_in_existing_dir() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
assert!(theme_dir.join("dark.md").exists());
assert!(theme_dir.join("minimal.md").exists());
}
#[test]
fn test_seed_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), "").unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
}
#[test]
fn test_seed_vault_themes_preserves_existing_content() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#FF0000\"\n---\n# Custom\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
seed_vault_themes(vp);
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(
content.contains("#FF0000"),
"existing content must be preserved"
);
}
#[test]
fn test_ensure_vault_themes_creates_dir_and_defaults() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
assert!(vault.join("theme").is_dir());
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
}
#[test]
fn test_ensure_vault_themes_reseeds_empty_files() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
fs::write(theme_dir.join("default.md"), "").unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Is A: Theme"));
}
#[test]
fn test_ensure_vault_themes_preserves_custom_themes() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let theme_dir = vault.join("theme");
fs::create_dir_all(&theme_dir).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#123456\"\n---\n";
fs::write(theme_dir.join("default.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
ensure_vault_themes(vp).unwrap();
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("#123456"));
}
#[test]
fn test_restore_default_themes_creates_both_dirs() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
let msg = restore_default_themes(vp).unwrap();
assert_eq!(msg, "Default themes restored");
assert!(vault.join("_themes").join("default.json").exists());
assert!(vault.join("_themes").join("dark.json").exists());
assert!(vault.join("_themes").join("minimal.json").exists());
assert!(vault.join("theme").join("default.md").exists());
assert!(vault.join("theme").join("dark.md").exists());
assert!(vault.join("theme").join("minimal.md").exists());
assert!(
vault.join("type").join("theme.md").exists(),
"restore must create type/theme.md"
);
let type_content = fs::read_to_string(vault.join("type").join("theme.md")).unwrap();
assert!(type_content.contains("Is A: Type"));
assert!(type_content.contains("icon: palette"));
}
#[test]
fn test_ensure_theme_type_definition_creates_file() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
ensure_theme_type_definition(vp).unwrap();
let path = vault.join("type").join("theme.md");
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Is A: Type"));
assert!(content.contains("icon: palette"));
}
#[test]
fn test_ensure_theme_type_definition_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let type_dir = vault.join("type");
fs::create_dir_all(&type_dir).unwrap();
let custom = "---\nIs A: Type\nicon: swatches\ncolor: green\n---\n# Theme\n";
fs::write(type_dir.join("theme.md"), custom).unwrap();
let vp = vault.to_str().unwrap();
ensure_theme_type_definition(vp).unwrap();
let content = fs::read_to_string(type_dir.join("theme.md")).unwrap();
assert!(
content.contains("swatches"),
"existing content must be preserved"
);
}
#[test]
fn test_restore_default_themes_is_idempotent() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
fs::create_dir_all(&vault).unwrap();
let vp = vault.to_str().unwrap();
restore_default_themes(vp).unwrap();
let custom = "---\nIs A: Theme\nbackground: \"#CUSTOM\"\n---\n";
fs::write(vault.join("theme").join("default.md"), custom).unwrap();
restore_default_themes(vp).unwrap();
let content = fs::read_to_string(vault.join("theme").join("default.md")).unwrap();
assert!(
content.contains("#CUSTOM"),
"must not overwrite existing content"
);
}
#[test]
fn test_restore_default_themes_fills_partial_state() {
let dir = TempDir::new().unwrap();
let vault = dir.path().join("vault");
let themes_dir = vault.join("_themes");
let theme_dir = vault.join("theme");
fs::create_dir_all(&themes_dir).unwrap();
fs::create_dir_all(&theme_dir).unwrap();
fs::write(themes_dir.join("default.json"), DEFAULT_THEME).unwrap();
fs::write(theme_dir.join("default.md"), DEFAULT_VAULT_THEME).unwrap();
let vp = vault.to_str().unwrap();
restore_default_themes(vp).unwrap();
assert!(themes_dir.join("dark.json").exists());
assert!(themes_dir.join("minimal.json").exists());
assert!(theme_dir.join("dark.md").exists());
assert!(theme_dir.join("minimal.md").exists());
let content = fs::read_to_string(theme_dir.join("default.md")).unwrap();
assert!(content.contains("Light theme with warm"));
}
}

View File

@@ -7,7 +7,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
// --- Vault Cache ---
/// Bump this when VaultEntry fields change to force a full rescan.
const CACHE_VERSION: u32 = 4;
const CACHE_VERSION: u32 = 5;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {
@@ -79,15 +79,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
.map(|s| collect_md_paths_from_diff(&s))
.unwrap_or_default();
// Use ls-files for untracked files so that newly-seeded directories are picked up
// as individual files rather than as a single "?? dirname/" entry.
// Include uncommitted changes (modified, staged, and untracked files).
let uncommitted = git_uncommitted_files(vault);
// Also include modified-but-unstaged files via status --porcelain.
let modified = run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default();
for path in uncommitted.into_iter().chain(modified) {
for path in uncommitted.into_iter() {
if !files.contains(&path) {
files.push(path);
}
@@ -97,9 +92,30 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
}
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
run_git(vault, &["status", "--porcelain"])
// Modified/staged tracked files from git status --porcelain
let mut files: Vec<String> = run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default()
.unwrap_or_default();
// Untracked files via ls-files (lists individual files, not just directories).
// git status --porcelain shows `?? dir/` for new directories, hiding individual
// files inside — ls-files resolves them so the cache picks up all new .md files.
let untracked = run_git(vault, &["ls-files", "--others", "--exclude-standard"])
.map(|s| {
s.lines()
.filter(|l| !l.is_empty() && l.ends_with(".md"))
.map(|l| l.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
for path in untracked {
if !files.contains(&path) {
files.push(path);
}
}
files
}
fn load_cache(vault: &Path) -> Option<VaultCache> {
@@ -140,21 +156,47 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
.collect()
}
/// Ensure `.laputa-cache.json` is excluded from git via `.git/info/exclude`.
/// This prevents the cache (which contains machine-specific absolute paths)
/// from being committed and causing stale-path bugs on cloned vaults.
/// Machine-local files that should never be git-tracked in any vault.
/// These are either caches with absolute paths or per-machine settings.
const UNTRACKED_FILES: &[&str] = &[
".laputa-cache.json",
".laputa/settings.json",
];
/// Ensure machine-local files are excluded from git via `.git/info/exclude`
/// and un-tracked if they were previously committed (git rm --cached).
/// Called on every cache write so existing vaults self-heal automatically.
fn ensure_cache_excluded(vault: &Path) {
let exclude_path = vault.join(".git/info/exclude");
let entry = ".laputa-cache.json";
if let Ok(content) = fs::read_to_string(&exclude_path) {
if content.lines().any(|line| line.trim() == entry) {
return;
}
let separator = if content.ends_with('\n') { "" } else { "\n" };
let _ = fs::write(&exclude_path, format!("{content}{separator}{entry}\n"));
} else if exclude_path.parent().map(|p| p.is_dir()).unwrap_or(false) {
let _ = fs::write(&exclude_path, format!("{entry}\n"));
let git_dir = vault.join(".git");
if !git_dir.is_dir() {
return;
}
let exclude_path = git_dir.join("info").join("exclude");
// 1. Add each entry to .git/info/exclude so git ignores it going forward.
let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
let mut to_add: Vec<&str> = UNTRACKED_FILES
.iter()
.filter(|e| !existing.lines().any(|line| line.trim() == **e))
.copied()
.collect();
if !to_add.is_empty() {
to_add.sort();
let separator = if existing.ends_with('\n') || existing.is_empty() { "" } else { "\n" };
let additions = to_add.join("\n");
let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n"));
}
// 2. Un-track each file if git currently tracks it.
// `git rm --cached --quiet --ignore-unmatch` exits 0 even if the file isn't tracked.
// This fixes existing vaults where these files were committed before this guard.
let _ = std::process::Command::new("git")
.args(["rm", "--cached", "--quiet", "--ignore-unmatch", "--"])
.args(UNTRACKED_FILES)
.current_dir(vault)
.output();
}
/// Sort entries by modified_at descending and write the cache.
@@ -541,4 +583,66 @@ mod tests {
assert!(titles.contains(&"Existing"));
assert!(titles.contains(&"New Note"));
}
#[test]
fn test_update_same_commit_new_files_in_new_subdirectory() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
create_test_file(vault, "existing.md", "# Existing\n");
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Prime cache
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
// Create files in a new subdirectory (simulates restore_default_themes)
create_test_file(
vault,
"theme/default.md",
"---\nIs A: Theme\n---\n# Default Theme\n",
);
create_test_file(
vault,
"theme/dark.md",
"---\nIs A: Theme\n---\n# Dark Theme\n",
);
// Cache same commit — files in new subdirectory must appear
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(
entries2.len(),
3,
"must pick up files in new untracked subdirectory"
);
let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect();
assert!(titles.contains(&"Existing"));
assert!(titles.contains(&"Default Theme"));
assert!(titles.contains(&"Dark Theme"));
}
}

View File

@@ -133,6 +133,10 @@ const SAMPLE_FILES: &[SampleFile] = &[
rel_path: "type/topic.md",
content: "---\nIs A: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
},
SampleFile {
rel_path: "type/theme.md",
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
},
SampleFile {
rel_path: "note/welcome-to-laputa.md",
content: r#"---
@@ -394,7 +398,7 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
}
// Seed built-in themes
// Seed built-in themes (both JSON legacy and vault-based markdown notes)
let themes_dir = vault_dir.join("_themes");
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
@@ -405,6 +409,25 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
fs::write(themes_dir.join("minimal.json"), crate::theme::MINIMAL_THEME)
.map_err(|e| format!("Failed to write minimal theme: {e}"))?;
let theme_notes_dir = vault_dir.join("theme");
fs::create_dir_all(&theme_notes_dir)
.map_err(|e| format!("Failed to create theme directory: {e}"))?;
fs::write(
theme_notes_dir.join("default.md"),
crate::theme::DEFAULT_VAULT_THEME,
)
.map_err(|e| format!("Failed to write default vault theme: {e}"))?;
fs::write(
theme_notes_dir.join("dark.md"),
crate::theme::DARK_VAULT_THEME,
)
.map_err(|e| format!("Failed to write dark vault theme: {e}"))?;
fs::write(
theme_notes_dir.join("minimal.md"),
crate::theme::MINIMAL_VAULT_THEME,
)
.map_err(|e| format!("Failed to write minimal vault theme: {e}"))?;
crate::git::init_repo(target_path)?;
Ok(vault_dir
@@ -507,8 +530,8 @@ mod tests {
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
let entries = crate::vault::scan_vault(&vault_path).unwrap();
// SAMPLE_FILES + AGENTS.md
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
// SAMPLE_FILES + AGENTS.md + 3 vault theme notes (theme/default.md, dark.md, minimal.md)
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3);
}
#[test]
@@ -583,12 +606,21 @@ mod tests {
let vault_path = dir.path().join("theme-vault");
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
// JSON legacy themes
assert!(vault_path.join("_themes/default.json").exists());
assert!(vault_path.join("_themes/dark.json").exists());
assert!(vault_path.join("_themes/minimal.json").exists());
let themes = crate::theme::list_themes(vault_path.to_str().unwrap()).unwrap();
assert_eq!(themes.len(), 3);
// Vault-based theme notes
assert!(vault_path.join("theme/default.md").exists());
assert!(vault_path.join("theme/dark.md").exists());
assert!(vault_path.join("theme/minimal.md").exists());
// Theme type definition
assert!(vault_path.join("type/theme.md").exists());
}
#[test]

View File

@@ -14,8 +14,8 @@ pub use rename::{rename_note, RenameResult};
pub use trash::{delete_note, purge_trash};
use parsing::{
capitalize_first, contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet,
extract_title, parse_iso_date,
contains_wikilink, count_body_words, extract_outgoing_links, extract_snippet, extract_title,
parse_iso_date, title_case_folder,
};
use gray_matter::engine::YAML;
@@ -71,6 +71,11 @@ pub struct VaultEntry {
/// Default sort preference for the note list when viewing instances of this Type.
/// Stored as "option:direction" (e.g. "modified:desc", "title:asc", "property:Priority:asc").
pub sort: Option<String>,
/// Default view mode for the note list when viewing instances of this Type.
/// Stored as a string: "all", "editor-list", or "editor-only".
pub view: Option<String>,
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
pub visible: Option<bool>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -103,9 +108,9 @@ struct Frontmatter {
cadence: Option<String>,
#[serde(rename = "Archived")]
archived: Option<bool>,
#[serde(rename = "Trashed")]
#[serde(rename = "Trashed", alias = "trashed")]
trashed: Option<bool>,
#[serde(rename = "Trashed at")]
#[serde(rename = "Trashed at", alias = "trashed_at")]
trashed_at: Option<String>,
#[serde(rename = "Created at")]
created_at: Option<String>,
@@ -123,6 +128,10 @@ struct Frontmatter {
template: Option<String>,
#[serde(default)]
sort: Option<String>,
#[serde(default)]
view: Option<String>,
#[serde(default)]
visible: Option<bool>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -169,6 +178,8 @@ const SKIP_KEYS: &[&str] = &[
"sidebar label",
"template",
"sort",
"view",
"visible",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -268,7 +279,7 @@ fn infer_type_from_folder(folder: &str) -> String {
"month" => "Month",
"essay" => "Essay",
"evergreen" => "Evergreen",
_ => return capitalize_first(folder),
_ => return title_case_folder(folder),
}
.to_string()
}
@@ -394,6 +405,8 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
sidebar_label: frontmatter.sidebar_label,
template: frontmatter.template,
sort: frontmatter.sort,
view: frontmatter.view,
visible: frontmatter.visible,
word_count,
outgoing_links,
properties,
@@ -953,6 +966,28 @@ References:
assert_eq!(entry.is_a, Some("Recipe".to_string()));
}
#[test]
fn test_infer_type_from_hyphenated_folder_title_cases() {
let dir = TempDir::new().unwrap();
let cases = vec![
("monday-ideas", "Monday Ideas"),
("key-result", "Key Result"),
("my_custom_type", "My Custom Type"),
("mix-and_match", "Mix And Match"),
];
for (folder, expected) in cases {
create_test_file(dir.path(), &format!("{}/test.md", folder), "# Test\n");
let entry = parse_md_file(&dir.path().join(folder).join("test.md")).unwrap();
assert_eq!(
entry.is_a,
Some(expected.to_string()),
"folder '{}' should infer type '{}'",
folder,
expected
);
}
}
#[test]
fn test_infer_type_frontmatter_overrides_folder() {
let dir = TempDir::new().unwrap();
@@ -1312,6 +1347,81 @@ Company: Acme Corp
);
}
#[test]
fn test_parse_trashed_title_case() {
let dir = TempDir::new().unwrap();
let content = "---\nTrashed: true\nTrashed at: \"2025-02-01\"\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone.md", content);
assert!(entry.trashed);
assert!(entry.trashed_at.is_some());
}
#[test]
fn test_parse_trashed_lowercase_alias() {
let dir = TempDir::new().unwrap();
let content = "---\ntrashed: true\ntrashed_at: \"2025-02-01\"\n---\n# Gone\n";
let entry = parse_test_entry(&dir, "gone.md", content);
assert!(
entry.trashed,
"lowercase 'trashed' must be parsed via alias"
);
assert!(
entry.trashed_at.is_some(),
"lowercase 'trashed_at' must be parsed via alias"
);
}
#[test]
fn test_trashed_false_when_absent() {
let dir = TempDir::new().unwrap();
let content = "---\nIs A: Note\n---\n# Active\n";
let entry = parse_test_entry(&dir, "active.md", content);
assert!(!entry.trashed);
assert!(entry.trashed_at.is_none());
}
// --- visible field tests ---
#[test]
fn test_parse_visible_false_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert_eq!(entry.visible, Some(false));
}
#[test]
fn test_parse_visible_true_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: true\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.visible, Some(true));
}
#[test]
fn test_parse_visible_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.visible, None);
}
#[test]
fn test_visible_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert!(entry.relationships.get("visible").is_none());
}
#[test]
fn test_visible_not_in_properties() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nvisible: false\n---\n# Journal\n";
let entry = parse_test_entry(&dir, "type/journal.md", content);
assert!(entry.properties.get("visible").is_none());
}
// Frontmatter update/delete tests are in frontmatter.rs
// save_image tests are in vault/image.rs
// purge_trash tests are in vault/trash.rs

View File

@@ -116,6 +116,14 @@ fn strip_markdown_chars(s: &str) -> String {
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'[' if chars.peek() == Some(&'[') => {
chars.next(); // consume second '['
let inner = collect_wikilink_inner(&mut chars);
match inner.find('|') {
Some(idx) => result.push_str(&inner[idx + 1..]),
None => result.push_str(&inner),
}
}
'[' => {
let inner = collect_until(&mut chars, ']');
if chars.peek() == Some(&'(') {
@@ -131,6 +139,19 @@ fn strip_markdown_chars(s: &str) -> String {
result
}
/// Collect chars inside a wikilink until `]]`, consuming both closing brackets.
fn collect_wikilink_inner(chars: &mut std::iter::Peekable<impl Iterator<Item = char>>) -> String {
let mut buf = String::new();
while let Some(c) = chars.next() {
if c == ']' && chars.peek() == Some(&']') {
chars.next();
break;
}
buf.push(c);
}
buf
}
/// Check if a string contains a wikilink pattern `[[...]]`.
pub(super) fn contains_wikilink(s: &str) -> bool {
s.contains("[[") && s.contains("]]")
@@ -174,6 +195,16 @@ pub(super) fn capitalize_first(s: &str) -> String {
}
}
/// Title-case a folder name: split on hyphens and underscores, capitalize each word, join with spaces.
/// Example: "monday-ideas" → "Monday Ideas", "key_result" → "Key Result"
pub(super) fn title_case_folder(s: &str) -> String {
s.split(['-', '_'])
.filter(|w| !w.is_empty())
.map(capitalize_first)
.collect::<Vec<_>>()
.join(" ")
}
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
@@ -250,6 +281,16 @@ mod tests {
let snippet = extract_snippet(content);
assert!(snippet.contains("this link"));
assert!(!snippet.contains("https://example.com"));
assert!(snippet.contains("wiki link"));
assert!(!snippet.contains("[["));
assert!(!snippet.contains("]]"));
}
#[test]
fn test_extract_snippet_wikilink_alias() {
let content = "# Title\n\nDiscussed in [[meetings/standup|standup]] today.";
let snippet = extract_snippet(content);
assert_eq!(snippet, "Discussed in standup today.");
}
#[test]
@@ -378,7 +419,20 @@ mod tests {
#[test]
fn test_strip_markdown_chars_wikilink() {
assert_eq!(strip_markdown_chars("see [[my note]]"), "see [my note]");
assert_eq!(strip_markdown_chars("see [[my note]]"), "see my note");
}
#[test]
fn test_strip_markdown_chars_wikilink_alias() {
assert_eq!(
strip_markdown_chars("visit [[project/alpha|Alpha Project]]"),
"visit Alpha Project"
);
}
#[test]
fn test_strip_markdown_chars_wikilink_unclosed() {
assert_eq!(strip_markdown_chars("see [[broken link"), "see broken link");
}
#[test]
@@ -413,6 +467,29 @@ mod tests {
assert_eq!(capitalize_first("a"), "A");
}
// --- title_case_folder tests ---
#[test]
fn test_title_case_folder_hyphenated() {
assert_eq!(title_case_folder("monday-ideas"), "Monday Ideas");
assert_eq!(title_case_folder("key-result"), "Key Result");
}
#[test]
fn test_title_case_folder_underscored() {
assert_eq!(title_case_folder("my_custom_type"), "My Custom Type");
}
#[test]
fn test_title_case_folder_single_word() {
assert_eq!(title_case_folder("recipe"), "Recipe");
}
#[test]
fn test_title_case_folder_empty() {
assert_eq!(title_case_folder(""), "");
}
// --- without_h1_line tests ---
#[test]

View File

@@ -0,0 +1,481 @@
use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// Vault-wide UI configuration stored in `config/ui.config.md`.
///
/// This file is a regular vault note with YAML frontmatter, visible in the
/// sidebar under the "Config" section and editable like any note.
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct VaultConfig {
pub zoom: Option<f64>,
pub view_mode: Option<String>,
#[serde(default)]
pub tag_colors: Option<HashMap<String, String>>,
#[serde(default)]
pub status_colors: Option<HashMap<String, String>>,
#[serde(default)]
pub property_display_modes: Option<HashMap<String, String>>,
}
const CONFIG_DIR: &str = "config";
const CONFIG_FILENAME: &str = "ui.config.md";
fn config_path(vault_path: &str) -> std::path::PathBuf {
Path::new(vault_path).join(CONFIG_DIR).join(CONFIG_FILENAME)
}
/// Read the vault-wide UI config from `config/ui.config.md`.
/// Returns default values if the file doesn't exist.
pub fn get_vault_config(vault_path: &str) -> Result<VaultConfig, String> {
let path = config_path(vault_path);
if !path.exists() {
return Ok(VaultConfig::default());
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
parse_vault_config(&content)
}
/// Parse VaultConfig from markdown content with YAML frontmatter.
fn parse_vault_config(content: &str) -> Result<VaultConfig, String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let hash = match parsed.data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return Ok(VaultConfig::default()),
};
let json_map: serde_json::Map<String, serde_json::Value> =
hash.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
let json = serde_json::Value::Object(json_map);
serde_json::from_value(json.clone())
.or_else(|_| {
// If direct deserialization fails, strip the `type` field and retry
let mut map = match json {
serde_json::Value::Object(m) => m,
_ => return Ok(VaultConfig::default()),
};
map.remove("type");
serde_json::from_value(serde_json::Value::Object(map))
})
.map_err(|e| format!("Failed to parse config: {e}"))
}
/// Save the vault-wide UI config to `config/ui.config.md`.
/// Creates the directory and file if they don't exist.
pub fn save_vault_config(vault_path: &str, config: VaultConfig) -> Result<(), String> {
let path = config_path(vault_path);
let dir = Path::new(vault_path).join(CONFIG_DIR);
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let content = serialize_config(&config);
std::fs::write(&path, content).map_err(|e| format!("Failed to write config: {e}"))
}
/// Serialize VaultConfig to a markdown file with YAML frontmatter.
fn serialize_config(config: &VaultConfig) -> String {
let mut lines = vec!["---".to_string(), "type: config".to_string()];
if let Some(zoom) = config.zoom {
lines.push(format!("zoom: {zoom}"));
}
if let Some(ref mode) = config.view_mode {
lines.push(format!("view_mode: {mode}"));
}
append_string_map(&mut lines, "tag_colors", config.tag_colors.as_ref());
append_string_map(&mut lines, "status_colors", config.status_colors.as_ref());
append_string_map(
&mut lines,
"property_display_modes",
config.property_display_modes.as_ref(),
);
lines.push("---".to_string());
lines.join("\n") + "\n"
}
/// Append a YAML map section with sorted keys for stable output.
fn append_string_map(lines: &mut Vec<String>, key: &str, map: Option<&HashMap<String, String>>) {
if let Some(m) = map {
if !m.is_empty() {
lines.push(format!("{key}:"));
let mut entries: Vec<_> = m.iter().collect();
entries.sort_by_key(|(k, _)| k.to_owned());
for (k, v) in entries {
lines.push(format!(" {}: {}", yaml_safe_key(k), yaml_safe_value(v)));
}
}
}
}
/// Quote a YAML key if it contains special characters.
fn yaml_safe_key(key: &str) -> String {
if key.contains(':') || key.contains('#') || key.contains(' ') {
format!("\"{}\"", key.replace('"', "\\\""))
} else {
key.to_string()
}
}
/// Quote a YAML value if it contains special characters.
fn yaml_safe_value(value: &str) -> String {
if value.contains(':')
|| value.contains('#')
|| value.starts_with('"')
|| value.starts_with('\'')
{
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_string()
}
}
/// Migrate `hidden_sections` from `config/ui.config.md` to `visible: false`
/// on Type notes. Returns the number of Type notes updated.
///
/// For each type name in `hidden_sections`:
/// - If `type/<slug>.md` exists, adds `visible: false` to its frontmatter
/// - If it doesn't exist, creates it with `type: Type`, `title: <name>`, `visible: false`
/// - Re-saves the config without `hidden_sections`
pub fn migrate_hidden_sections_to_visible(vault_path: &str) -> Result<usize, String> {
let path = config_path(vault_path);
if !path.exists() {
return Ok(0);
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read config: {e}"))?;
let hidden = extract_hidden_sections(&content);
if hidden.is_empty() {
return Ok(0);
}
let type_dir = Path::new(vault_path).join("type");
if !type_dir.exists() {
std::fs::create_dir_all(&type_dir)
.map_err(|e| format!("Failed to create type dir: {e}"))?;
}
let mut migrated = 0;
for type_name in &hidden {
let slug = type_name_to_slug(type_name);
let type_path = type_dir.join(format!("{slug}.md"));
if type_path.exists() {
let type_content = std::fs::read_to_string(&type_path)
.map_err(|e| format!("Failed to read {}: {e}", type_path.display()))?;
if !type_content.contains("visible:") {
let updated = crate::frontmatter::update_frontmatter_content(
&type_content,
"visible",
Some(crate::frontmatter::FrontmatterValue::Bool(false)),
)
.map_err(|e| format!("Failed to update {}: {e}", type_path.display()))?;
std::fs::write(&type_path, updated)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
} else {
let new_content = format!(
"---\ntype: Type\ntitle: {}\nvisible: false\n---\n\n# {}\n",
type_name, type_name
);
std::fs::write(&type_path, new_content)
.map_err(|e| format!("Failed to write {}: {e}", type_path.display()))?;
}
migrated += 1;
}
// Re-save config without hidden_sections
let config = parse_vault_config(&content)?;
save_vault_config(vault_path, config)?;
Ok(migrated)
}
/// Extract `hidden_sections` from raw YAML frontmatter.
fn extract_hidden_sections(content: &str) -> Vec<String> {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(content);
let hash = match parsed.data {
Some(gray_matter::Pod::Hash(map)) => map,
_ => return vec![],
};
match hash.get("hidden_sections") {
Some(gray_matter::Pod::Array(arr)) => arr
.iter()
.filter_map(|v| match v {
gray_matter::Pod::String(s) => Some(s.clone()),
_ => None,
})
.collect(),
_ => vec![],
}
}
/// Convert a Type name to a filesystem slug.
fn type_name_to_slug(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Convert gray_matter::Pod to serde_json::Value.
fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value {
match pod {
gray_matter::Pod::String(s) => serde_json::Value::String(s),
gray_matter::Pod::Integer(i) => serde_json::json!(i),
gray_matter::Pod::Float(f) => serde_json::json!(f),
gray_matter::Pod::Boolean(b) => serde_json::Value::Bool(b),
gray_matter::Pod::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(pod_to_json).collect())
}
gray_matter::Pod::Hash(map) => {
let obj: serde_json::Map<String, serde_json::Value> =
map.into_iter().map(|(k, v)| (k, pod_to_json(v))).collect();
serde_json::Value::Object(obj)
}
gray_matter::Pod::Null => serde_json::Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_returns_defaults() {
let config = parse_vault_config("").unwrap();
assert!(config.zoom.is_none());
assert!(config.view_mode.is_none());
assert!(config.tag_colors.is_none());
}
#[test]
fn parse_full_config() {
let content = r#"---
type: config
zoom: 1.1
view_mode: all
tag_colors:
engineering: blue
personal: green
status_colors:
Active: green
Done: blue
property_display_modes:
deadline: date
---
"#;
let config = parse_vault_config(content).unwrap();
assert_eq!(config.zoom, Some(1.1));
assert_eq!(config.view_mode.as_deref(), Some("all"));
let tags = config.tag_colors.unwrap();
assert_eq!(tags.get("engineering").unwrap(), "blue");
assert_eq!(tags.get("personal").unwrap(), "green");
let statuses = config.status_colors.unwrap();
assert_eq!(statuses.get("Active").unwrap(), "green");
let props = config.property_display_modes.unwrap();
assert_eq!(props.get("deadline").unwrap(), "date");
}
#[test]
fn roundtrip_serialization() {
let mut tag_colors = HashMap::new();
tag_colors.insert("work".to_string(), "blue".to_string());
let config = VaultConfig {
zoom: Some(1.2),
view_mode: Some("editor-only".to_string()),
tag_colors: Some(tag_colors),
status_colors: None,
property_display_modes: None,
};
let serialized = serialize_config(&config);
let parsed = parse_vault_config(&serialized).unwrap();
assert_eq!(parsed.zoom, Some(1.2));
assert_eq!(parsed.view_mode.as_deref(), Some("editor-only"));
assert_eq!(parsed.tag_colors.unwrap().get("work").unwrap(), "blue");
}
#[test]
fn get_config_missing_file() {
let dir = tempfile::TempDir::new().unwrap();
let config = get_vault_config(dir.path().to_str().unwrap()).unwrap();
assert!(config.zoom.is_none());
}
#[test]
fn save_and_read_config() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let mut status_colors = HashMap::new();
status_colors.insert("Active".to_string(), "green".to_string());
let config = VaultConfig {
zoom: Some(0.9),
view_mode: None,
tag_colors: None,
status_colors: Some(status_colors),
property_display_modes: None,
};
save_vault_config(vault_path, config).unwrap();
let loaded = get_vault_config(vault_path).unwrap();
assert_eq!(loaded.zoom, Some(0.9));
assert_eq!(
loaded.status_colors.unwrap().get("Active").unwrap(),
"green"
);
}
#[test]
fn migrate_hidden_sections_creates_type_notes_with_visible_false() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Bookmark\n - Recipe\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 2);
// Check type notes were created
let bookmark = std::fs::read_to_string(dir.path().join("type/bookmark.md")).unwrap();
assert!(bookmark.contains("visible: false"));
assert!(bookmark.contains("title: Bookmark"));
let recipe = std::fs::read_to_string(dir.path().join("type/recipe.md")).unwrap();
assert!(recipe.contains("visible: false"));
// Config should no longer have hidden_sections
let config_content = std::fs::read_to_string(config_dir.join("ui.config.md")).unwrap();
assert!(!config_content.contains("hidden_sections"));
}
#[test]
fn migrate_hidden_sections_updates_existing_type_note() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create config with hidden_sections
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Project\n---\n",
)
.unwrap();
// Create existing type note without visible
let type_dir = dir.path().join("type");
std::fs::create_dir_all(&type_dir).unwrap();
std::fs::write(
type_dir.join("project.md"),
"---\ntype: Type\ntitle: Project\nicon: briefcase\n---\n\n# Project\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(type_dir.join("project.md")).unwrap();
assert!(content.contains("visible: false"));
assert!(
content.contains("icon: briefcase"),
"should preserve existing fields"
);
}
#[test]
fn migrate_hidden_sections_skips_when_no_hidden_sections() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nzoom: 1.0\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_skips_when_no_config_file() {
let dir = tempfile::TempDir::new().unwrap();
let count = migrate_hidden_sections_to_visible(dir.path().to_str().unwrap()).unwrap();
assert_eq!(count, 0);
}
#[test]
fn migrate_hidden_sections_does_not_duplicate_visible() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("ui.config.md"),
"---\ntype: config\nhidden_sections:\n - Note\n---\n",
)
.unwrap();
// Type note already has visible: false
let type_dir = dir.path().join("type");
std::fs::create_dir_all(&type_dir).unwrap();
std::fs::write(
type_dir.join("note.md"),
"---\ntype: Type\ntitle: Note\nvisible: false\n---\n",
)
.unwrap();
let count = migrate_hidden_sections_to_visible(vault_path).unwrap();
assert_eq!(count, 1);
let content = std::fs::read_to_string(type_dir.join("note.md")).unwrap();
// Should have exactly one visible: false, not two
assert_eq!(content.matches("visible:").count(), 1);
}
#[test]
fn type_name_to_slug_converts_names() {
assert_eq!(type_name_to_slug("Project"), "project");
assert_eq!(type_name_to_slug("Weekly Review"), "weekly-review");
assert_eq!(type_name_to_slug("My Note!"), "my-note");
}
#[test]
fn yaml_safe_key_quoting() {
assert_eq!(yaml_safe_key("simple"), "simple");
assert_eq!(yaml_safe_key("has space"), "\"has space\"");
assert_eq!(yaml_safe_key("has:colon"), "\"has:colon\"");
}
}

View File

@@ -7,7 +7,7 @@
"frontendDist": "../dist",
"devUrl": "http://localhost:5202",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp"
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp && bash scripts/bundle-qmd.sh"
},
"app": {
"withGlobalTauri": true,
@@ -38,7 +38,8 @@
"targets": "all",
"createUpdaterArtifacts": true,
"resources": {
"resources/mcp-server/**/*": "mcp-server/"
"resources/mcp-server/**/*": "mcp-server/",
"resources/qmd/**/*": "qmd/"
},
"icon": [
"icons/32x32.png",

View File

@@ -61,3 +61,14 @@
.tab-status-pulse {
animation: tab-status-pulse 1.2s ease-in-out infinite;
}
/* AI highlight animation — applied by useAiActivity when MCP calls ui_highlight */
@keyframes ai-highlight-glow {
0% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0.8); }
50% { box-shadow: inset 0 0 8px 2px rgba(99, 102, 241, 0.4); }
100% { box-shadow: inset 0 0 0 2px rgba(99, 102, 241, 0); }
}
.ai-highlight {
animation: ai-highlight-glow 0.8s ease-out;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import { Editor } from './components/Editor'
@@ -9,11 +9,12 @@ import { CommandPalette } from './components/CommandPalette'
import { SearchPanel } from './components/SearchPanel'
import { Toast } from './components/Toast'
import { CommitDialog } from './components/CommitDialog'
import { PulseView } from './components/PulseView'
import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { useMcpRegistration } from './hooks/useMcpRegistration'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
@@ -30,16 +31,21 @@ import { useAutoSync } from './hooks/useAutoSync'
import { useConflictResolver } from './hooks/useConflictResolver'
import { useIndexing } from './hooks/useIndexing'
import { useZoom } from './hooks/useZoom'
import { useVaultConfig } from './hooks/useVaultConfig'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useNavigationGestures } from './hooks/useNavigationGestures'
import { useAiActivity } from './hooks/useAiActivity'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection } from './types'
import type { SidebarSelection, VaultEntry } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries } from './utils/noteListHelpers'
import { openLocalFile } from './utils/url'
import './App.css'
// Type declaration for mock content storage
@@ -99,10 +105,11 @@ function App() {
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
useVaultConfig(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent)
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)
useMcpRegistration(resolvedPath, setToastMessage)
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
@@ -132,12 +139,27 @@ function App() {
onOpenFile: (relativePath) => openConflictFileRef.current(relativePath),
})
const handleOpenConflictResolver = useCallback(() => {
if (autoSync.conflictFiles.length === 0) return
const handleOpenConflictResolver = useCallback(async () => {
let files = autoSync.conflictFiles
// If no cached conflicts, check directly — there may be pre-existing
// conflicts from a prior session that the pull flow didn't detect.
if (files.length === 0) {
try {
files = isTauri()
? await invoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
: await mockInvoke<string[]>('get_conflict_files', { vaultPath: resolvedPath })
} catch {
return
}
if (files.length === 0) {
setToastMessage('No merge conflicts to resolve')
return
}
}
autoSync.pausePull()
conflictResolver.initFiles(autoSync.conflictFiles)
conflictResolver.initFiles(files)
dialogs.openConflictResolver()
}, [autoSync, conflictResolver, dialogs])
}, [autoSync, conflictResolver, dialogs, resolvedPath])
const handleCloseConflictResolver = useCallback(() => {
autoSync.resumePull()
@@ -208,6 +230,45 @@ function App() {
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
const openNoteByPath = useCallback((path: string) => {
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
if (entry) {
notes.handleSelectNote(entry)
} else {
// Entry not yet in vault (just created) — reload then open
vault.reloadVault().then(freshEntries => {
const fresh = freshEntries.find((e: VaultEntry) => e.path === path || e.path === `${resolvedPath}/${path}`)
if (fresh) notes.handleSelectNote(fresh)
})
}
}, [vault, notes, resolvedPath])
const aiActivity = useAiActivity({
onOpenNote: openNoteByPath,
onOpenTab: openNoteByPath,
onSetFilter: (filterType) => {
setSelection({ kind: 'sectionGroup', type: filterType })
},
onVaultChanged: () => { vault.reloadVault() },
})
// Agent file operation handlers: auto-open created notes, live-refresh modified notes
const handleAgentFileCreated = useCallback((relativePath: string) => {
vault.reloadVault().then(freshEntries => {
const entry = freshEntries.find((e: VaultEntry) => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
if (entry) notes.handleSelectNote(entry)
})
}, [vault, notes, resolvedPath])
const handleAgentFileModified = useCallback((relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const matchPath = notes.tabs.some(t => t.entry.path === relativePath) ? relativePath : fullPath
if (notes.tabs.some(t => t.entry.path === matchPath)) {
vault.reloadVault()
}
}, [vault, notes, resolvedPath])
const { triggerIncrementalIndex } = indexing
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
@@ -227,8 +288,13 @@ function App() {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath)
if (entry) {
// Markdown note — open inside Laputa editor
notes.handleSelectNote(entry)
dialogs.closeConflictResolver()
} else {
// Non-note file (e.g. .laputa-cache.json, settings.json) —
// open with system default app so the user can inspect/edit it
openLocalFile(fullPath)
}
}
}, [resolvedPath, vault.entries, notes, dialogs])
@@ -314,6 +380,19 @@ function App() {
// 'available' → UpdateBanner handles it automatically
}, [updateActions, updateStatus.state, setToastMessage])
const handleRestoreDefaultThemes = useCallback(async () => {
if (!resolvedPath) return
try {
const tauriInvoke = isTauri() ? invoke : mockInvoke
const msg = await tauriInvoke<string>('restore_default_themes', { vaultPath: resolvedPath })
await vault.reloadVault()
await themeManager.reloadThemes()
setToastMessage(msg)
} catch (err) {
setToastMessage(`Failed to restore themes: ${err}`)
}
}, [resolvedPath, vault, themeManager, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
@@ -332,7 +411,6 @@ function App() {
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog,
onResolveConflicts: handleOpenConflictResolver,
conflictCount: autoSync.conflictFiles.length,
onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onToggleDiff: () => diffToggleRef.current(),
@@ -365,12 +443,28 @@ function App() {
onCheckForUpdates: handleCheckForUpdates,
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
onRestoreDefaultThemes: handleRestoreDefaultThemes,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
const aiNoteList = useMemo<NoteListItem[]>(() => {
return filterEntries(vault.entries, selection).map(e => ({
path: e.path, title: e.title, type: e.isA ?? 'Note',
}))
}, [vault.entries, selection])
const aiNoteListFilter = useMemo(() => {
if (selection.kind === 'sectionGroup') return { type: selection.type, query: '' }
if (selection.kind === 'topic') return { type: null, query: selection.entry.title }
if (selection.kind === 'entity') return { type: null, query: selection.entry.title }
return { type: null, query: '' }
}, [selection])
// Show welcome/onboarding screen when vault doesn't exist
if (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing') {
const defaultPath = onboarding.state.defaultPath
@@ -406,20 +500,28 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
<Sidebar entries={vault.entries} selection={selection} onSelect={setSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
)}
{noteListVisible && (
<>
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={(relativePath) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath || e.path === relativePath)
if (entry) notes.handleSelectNote(entry)
}} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
)}
<div className="app__editor">
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
<Editor
tabs={notes.tabs}
activeTabPath={notes.activeTabPath}
@@ -446,6 +548,8 @@ function App() {
showAIChat={dialogs.showAIChat}
onToggleAIChat={dialogs.toggleAIChat}
vaultPath={resolvedPath}
noteList={aiNoteList}
noteListFilter={aiNoteListFilter}
onTrashNote={entryActions.handleTrashNote}
onRestoreNote={entryActions.handleRestoreNote}
onDeleteNote={handleDeleteNote}
@@ -463,11 +567,13 @@ function App() {
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
isDarkTheme={themeManager.isDark}
onFileCreated={handleAgentFileCreated}
onFileModified={handleAgentFileModified}
/>
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRemoveVault={vaultSwitcher.removeVault} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRetryIndexing={indexing.retryIndexing} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />

View File

@@ -66,8 +66,8 @@ describe('AiActionCard', () => {
expect(header.getAttribute('tabindex')).toBe('0')
})
it('uses lighter background for ui_ tools', () => {
render(<AiActionCard {...defaults} tool="ui_open_tab" label="Opened tab" />)
it('uses lighter background for open_note tool', () => {
render(<AiActionCard {...defaults} tool="open_note" label="Opening note" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.06')
})

View File

@@ -1,7 +1,8 @@
import { type KeyboardEvent, type ReactNode, useCallback } from 'react'
import {
PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle,
PencilSimple, MagnifyingGlass, Trash, ChartBar, Eye,
CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown,
Terminal, File, FolderOpen, NotePencil,
} from '@phosphor-icons/react'
export type AiActionStatus = 'pending' | 'done' | 'error'
@@ -23,18 +24,21 @@ const MAX_DETAIL_LENGTH = 800
type IconRenderer = (size: number) => ReactNode
const TOOL_ICON_MAP: Record<string, IconRenderer> = {
create_note: (s) => <PencilSimple size={s} />,
edit_note_frontmatter: (s) => <PencilSimple size={s} />,
append_to_note: (s) => <PencilSimple size={s} />,
// Native Claude Code tools
Bash: (s) => <Terminal size={s} />,
Write: (s) => <PencilSimple size={s} />,
Edit: (s) => <NotePencil size={s} />,
Read: (s) => <File size={s} />,
Glob: (s) => <FolderOpen size={s} />,
Grep: (s) => <MagnifyingGlass size={s} />,
// Laputa MCP tools
search_notes: (s) => <MagnifyingGlass size={s} />,
list_notes: (s) => <MagnifyingGlass size={s} />,
link_notes: (s) => <Link size={s} />,
get_vault_context: (s) => <ChartBar size={s} />,
get_note: (s) => <File size={s} />,
open_note: (s) => <Eye size={s} />,
// Legacy tools (for backward compatibility with existing messages)
create_note: (s) => <PencilSimple size={s} />,
delete_note: (s) => <Trash size={s} />,
vault_context: (s) => <ChartBar size={s} />,
ui_open_note: (s) => <Eye size={s} />,
ui_open_tab: (s) => <Eye size={s} />,
ui_highlight: (s) => <Sparkle size={s} />,
ui_set_filter: (s) => <Sparkle size={s} />,
}
const DEFAULT_ICON: IconRenderer = (s) => <PencilSimple size={s} />
@@ -96,11 +100,15 @@ function DetailBlock({ label, content, isError }: {
)
}
/** Whether this tool is a Laputa UI-only tool (lighter styling). */
function isUiOnlyTool(tool: string): boolean {
return tool === 'open_note'
}
export function AiActionCard({
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
}: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const isUiTool = tool.startsWith('ui_')
const hasDetails = !!(input || output)
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -129,7 +137,7 @@ export function AiActionCard({
className="rounded"
style={{
fontSize: 12,
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
background: isUiOnlyTool(tool) ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
}}
>
<div

View File

@@ -2,15 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiMessage } from './AiMessage'
vi.mock('./MarkdownContent', () => ({
MarkdownContent: ({ content }: { content: string }) => <div data-testid="markdown-content">{content}</div>,
}))
describe('AiMessage', () => {
it('renders user message', () => {
render(<AiMessage userMessage="Hello AI" actions={[]} />)
expect(screen.getByText('Hello AI')).toBeTruthy()
})
it('renders response text', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the answer" />)
expect(screen.getByText('Here is the answer')).toBeTruthy()
it('renders response as markdown', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the **answer**" />)
expect(screen.getByTestId('markdown-content')).toBeTruthy()
expect(screen.getByText('Here is the **answer**')).toBeTruthy()
})
it('shows undo button with response', () => {
@@ -18,23 +23,31 @@ describe('AiMessage', () => {
expect(screen.getByTestId('undo-button')).toBeTruthy()
})
it('renders reasoning toggle collapsed by default', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
it('shows reasoning expanded while streaming (reasoningDone=false)', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." reasoningDone={false} actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
expect(screen.getByText('Thinking about it...')).toBeTruthy()
})
it('collapses reasoning on second click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." actions={[]} />)
it('auto-collapses reasoning when reasoningDone=true', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands collapsed reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone actions={[]} />)
// Starts collapsed (reasoningDone=true)
expect(screen.queryByTestId('reasoning-content')).toBeNull()
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
})
it('collapses expanded reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone={false} actions={[]} />)
// Starts expanded (reasoningDone=false)
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
@@ -89,6 +102,47 @@ describe('AiMessage', () => {
expect(screen.queryByTestId('ai-action-card')).toBeNull()
})
it('renders reference pills in user bubble', () => {
render(
<AiMessage
userMessage="Tell me about this"
references={[
{ title: 'Marco', path: 'person/marco.md', type: 'Person' },
{ title: 'Project X', path: 'project/x.md', type: 'Project' },
]}
actions={[]}
/>,
)
const pills = screen.getAllByTestId('message-reference-pill')
expect(pills).toHaveLength(2)
expect(pills[0].textContent).toBe('Marco')
expect(pills[1].textContent).toBe('Project X')
})
it('does not render pills when no references', () => {
render(<AiMessage userMessage="Hello" actions={[]} />)
expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0)
})
it('does not render pills when references array is empty', () => {
render(<AiMessage userMessage="Hello" references={[]} actions={[]} />)
expect(screen.queryAllByTestId('message-reference-pill')).toHaveLength(0)
})
it('calls onOpenNote when a reference pill is clicked', () => {
const onOpenNote = vi.fn()
render(
<AiMessage
userMessage="Check this"
references={[{ title: 'Alpha', path: 'note/alpha.md', type: 'Note' }]}
actions={[]}
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('message-reference-pill'))
expect(onOpenNote).toHaveBeenCalledWith('note/alpha.md')
})
it('expands and collapses action cards independently', () => {
render(
<AiMessage

View File

@@ -1,6 +1,9 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
import { AiActionCard, type AiActionStatus } from './AiActionCard'
import { MarkdownContent } from './MarkdownContent'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
export interface AiAction {
tool: string
@@ -14,14 +17,47 @@ export interface AiAction {
export interface AiMessageProps {
userMessage: string
references?: NoteReference[]
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
onOpenNote?: (path: string) => void
}
function UserBubble({ content }: { content: string }) {
function ReferencePill({ reference, onClick }: {
reference: NoteReference
onClick?: (path: string) => void
}) {
const color = getTypeColor(reference.type)
const lightColor = getTypeLightColor(reference.type)
return (
<button
className="inline-flex items-center border-none cursor-pointer transition-opacity hover:opacity-80"
style={{
background: lightColor,
color,
borderRadius: 9999,
padding: '1px 8px',
fontSize: 11,
fontWeight: 500,
fontFamily: 'inherit',
lineHeight: 1.4,
}}
onClick={() => onClick?.(reference.path)}
data-testid="message-reference-pill"
>
{reference.title}
</button>
)
}
function UserBubble({ content, references, onOpenNote }: {
content: string
references?: NoteReference[]
onOpenNote?: (path: string) => void
}) {
return (
<div className="flex justify-end" style={{ marginBottom: 8 }}>
<div
@@ -35,6 +71,13 @@ function UserBubble({ content }: { content: string }) {
lineHeight: 1.5,
}}
>
{references && references.length > 0 && (
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
{references.map(ref => (
<ReferencePill key={ref.path} reference={ref} onClick={onOpenNote} />
))}
</div>
)}
{content}
</div>
</div>
@@ -44,6 +87,14 @@ function UserBubble({ content }: { content: string }) {
function ReasoningBlock({ text, expanded, onToggle }: {
text: string; expanded: boolean; onToggle: () => void
}) {
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (expanded && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [expanded, text])
return (
<div style={{ marginBottom: 8 }}>
<button
@@ -58,8 +109,9 @@ function ReasoningBlock({ text, expanded, onToggle }: {
</button>
{expanded && (
<div
ref={contentRef}
className="text-muted-foreground"
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px' }}
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px', maxHeight: 200, overflowY: 'auto' }}
data-testid="reasoning-content"
>
{text}
@@ -98,7 +150,7 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
function ResponseBlock({ text }: { text: string }) {
return (
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>{text}</div>
<MarkdownContent content={text} />
<button
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
@@ -123,10 +175,16 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
const [reasoningExpanded, setReasoningExpanded] = useState(false)
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
// Auto: expanded while reasoning streams, collapsed once done
// User can manually toggle to override the auto state
const autoExpanded = !reasoningDone
const reasoningExpanded = userOverride ? !autoExpanded : autoExpanded
const toggleAction = useCallback((toolId: string) => {
setExpandedActions(prev => {
const next = new Set(prev)
@@ -138,12 +196,12 @@ export function AiMessage({ userMessage, reasoning, actions, response, isStreami
return (
<div data-testid="ai-message" style={{ marginBottom: 16 }}>
<UserBubble content={userMessage} />
<UserBubble content={userMessage} references={references} onOpenNote={onOpenNote} />
{reasoning && (
<ReasoningBlock
text={reasoning}
expanded={reasoningExpanded}
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
onToggle={() => setUserOverride(prev => !prev)}
/>
)}
{actions.length > 0 && (

View File

@@ -1,8 +1,9 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextualPrompt } from '../utils/ai-context'
import { WikilinkChatInput } from './WikilinkChatInput'
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
import type { VaultEntry } from '../types'
export type { AiAgentMessage } from '../hooks/useAiAgent'
@@ -10,10 +11,15 @@ export type { AiAgentMessage } from '../hooks/useAiAgent'
interface AiPanelProps {
onClose: () => void
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
vaultPath: string
activeEntry?: VaultEntry | null
entries?: VaultEntry[]
allContent?: Record<string, string>
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
}
function PanelHeader({ onClose, onClear }: { onClose: () => void; onClear: () => void }) {
@@ -103,54 +109,9 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
)
}
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext, inputRef }: {
input: string; onInputChange: (v: string) => void
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
isActive: boolean; hasContext: boolean; inputRef: React.RefObject<HTMLInputElement | null>
}) {
const sendDisabled = isActive || !input.trim()
return (
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<input
ref={inputRef}
value={input}
onChange={e => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
className="flex-1 border border-border bg-transparent text-foreground"
style={{
fontSize: 13, borderRadius: 8, padding: '8px 10px',
outline: 'none', fontFamily: 'inherit',
}}
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
disabled={isActive}
data-testid="agent-input"
/>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{
background: sendDisabled ? 'var(--muted)' : 'var(--primary)',
color: sendDisabled ? 'var(--muted-foreground)' : 'white',
borderRadius: 8, width: 32, height: 34,
cursor: sendDisabled ? 'not-allowed' : 'pointer',
}}
onClick={onSend}
disabled={sendDisabled}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
)
}
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
const [input, setInput] = useState('')
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const panelRef = useRef<HTMLElement>(null)
@@ -160,11 +121,24 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
}, [activeEntry, entries])
const contextPrompt = useMemo(() => {
if (!activeEntry || !allContent) return undefined
return buildContextualPrompt(activeEntry, linkedEntries, allContent)
}, [activeEntry, linkedEntries, allContent])
if (!activeEntry || !allContent || !entries) return undefined
return buildContextSnapshot({
activeEntry,
allContent,
openTabs,
noteList,
noteListFilter,
entries,
references: pendingRefs.length > 0 ? pendingRefs : undefined,
})
}, [activeEntry, allContent, openTabs, noteList, noteListFilter, entries, pendingRefs])
const agent = useAiAgent(vaultPath, contextPrompt)
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
onFileCreated,
onFileModified,
}), [onFileCreated, onFileModified])
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
@@ -193,26 +167,28 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
return () => window.removeEventListener('keydown', handleEscape)
}, [handleEscape])
const handleSend = () => {
if (!input.trim() || isActive) return
agent.sendMessage(input)
const handleSend = useCallback((text: string, references: NoteReference[]) => {
if (!text.trim() || isActive) return
setPendingRefs(references)
agent.sendMessage(text, references)
setInput('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
}, [isActive, agent])
return (
<aside
ref={panelRef}
tabIndex={-1}
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
style={{ outline: 'none' }}
className="flex flex-1 flex-col overflow-hidden bg-background text-foreground"
style={{
outline: 'none',
borderLeft: isActive
? '2px solid var(--accent-blue, #3b82f6)'
: '1px solid var(--border)',
animation: isActive ? 'ai-border-pulse 2s ease-in-out infinite' : undefined,
transition: 'border-color 0.3s ease',
}}
data-testid="ai-panel"
data-ai-active={isActive || undefined}
>
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
{activeEntry && (
@@ -224,15 +200,39 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
onOpenNote={onOpenNote}
hasContext={hasContext}
/>
<InputBar
input={input}
onInputChange={setInput}
onSend={handleSend}
onKeyDown={handleKeyDown}
isActive={isActive}
hasContext={hasContext}
inputRef={inputRef}
/>
<div
className="flex shrink-0 flex-col border-t border-border"
style={{ padding: '8px 12px' }}
>
<div className="flex items-end gap-2">
<div className="flex-1">
<WikilinkChatInput
entries={entries ?? []}
value={input}
onChange={setInput}
onSend={handleSend}
disabled={isActive}
placeholder={hasContext ? 'Ask about this note...' : 'Ask the AI agent...'}
inputRef={inputRef}
/>
</div>
<button
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
style={{
background: (isActive || !input.trim()) ? 'var(--muted)' : 'var(--primary)',
color: (isActive || !input.trim()) ? 'var(--muted-foreground)' : 'white',
borderRadius: 8, width: 32, height: 34,
cursor: (isActive || !input.trim()) ? 'not-allowed' : 'pointer',
}}
onClick={() => handleSend(input, pendingRefs)}
disabled={isActive || !input.trim()}
title="Send message"
data-testid="agent-send"
>
<PaperPlaneRight size={16} />
</button>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ColorSwatch, ColorEditableValue } from './ColorInput'
describe('ColorSwatch', () => {
it('renders a swatch button with the given color', () => {
render(<ColorSwatch color="#3b82f6" />)
const swatch = screen.getByTestId('color-swatch')
expect(swatch).toBeTruthy()
// Browser normalizes hex to rgb() in computed style
expect(swatch.style.background).toBeTruthy()
})
it('contains a hidden color input', () => {
render(<ColorSwatch color="#ff0000" />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
expect(input.type).toBe('color')
expect(input.value).toBe('#ff0000')
})
it('calls onChange when color is picked', () => {
const onChange = vi.fn()
render(<ColorSwatch color="#ff0000" onChange={onChange} />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
fireEvent.change(input, { target: { value: '#00ff00' } })
expect(onChange).toHaveBeenCalledWith('#00ff00')
})
it('opens color input on Enter key', () => {
const clickSpy = vi.fn()
render(<ColorSwatch color="#ff0000" />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
input.click = clickSpy
const swatch = screen.getByTestId('color-swatch')
fireEvent.keyDown(swatch, { key: 'Enter' })
expect(clickSpy).toHaveBeenCalled()
})
it('opens color input on Space key', () => {
const clickSpy = vi.fn()
render(<ColorSwatch color="#ff0000" />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
input.click = clickSpy
const swatch = screen.getByTestId('color-swatch')
fireEvent.keyDown(swatch, { key: ' ' })
expect(clickSpy).toHaveBeenCalled()
})
})
describe('ColorEditableValue', () => {
it('shows swatch when value is a valid hex color', () => {
render(<ColorEditableValue value="#3b82f6" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.getByTestId('color-swatch')).toBeTruthy()
})
it('does not show swatch when value is not a color', () => {
render(<ColorEditableValue value="hello world" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.queryByTestId('color-swatch')).toBeNull()
})
it('does not show swatch for empty string', () => {
render(<ColorEditableValue value="" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.queryByTestId('color-swatch')).toBeNull()
})
it('shows swatch in edit mode for valid color', () => {
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.getByTestId('color-swatch')).toBeTruthy()
expect(screen.getByTestId('color-text-input')).toBeTruthy()
})
it('updates value when color is picked', () => {
const onSave = vi.fn()
render(<ColorEditableValue value="#ff0000" isEditing={false} onStartEdit={vi.fn()} onSave={onSave} onCancel={vi.fn()} />)
const input = screen.getByTestId('color-picker-input') as HTMLInputElement
fireEvent.change(input, { target: { value: '#00ff00' } })
expect(onSave).toHaveBeenCalledWith('#00ff00')
})
it('text field works independently in edit mode', () => {
const onSave = vi.fn()
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={onSave} onCancel={vi.fn()} />)
const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
fireEvent.change(textInput, { target: { value: '#0000ff' } })
fireEvent.keyDown(textInput, { key: 'Enter' })
expect(onSave).toHaveBeenCalledWith('#0000ff')
})
it('Escape cancels edit', () => {
const onCancel = vi.fn()
render(<ColorEditableValue value="#ff0000" isEditing={true} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={onCancel} />)
const textInput = screen.getByTestId('color-text-input') as HTMLInputElement
fireEvent.keyDown(textInput, { key: 'Escape' })
expect(onCancel).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,113 @@
import { useState, useRef, useCallback } from 'react'
import { isValidCssColor, toHexColor } from '../utils/colorUtils'
/**
* Inline color swatch button that opens a native color picker.
* Shows nothing if the value is not a valid CSS color.
*/
export function ColorSwatch({ color, onChange }: {
color: string
onChange?: (hex: string) => void
}) {
const hex = toHexColor(color) ?? '#000000'
const inputRef = useRef<HTMLInputElement>(null)
const handleClick = useCallback(() => {
inputRef.current?.click()
}, [])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value)
}, [onChange])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
inputRef.current?.click()
}
}, [])
return (
<span className="inline-flex shrink-0 items-center">
<button
type="button"
className="relative size-4 shrink-0 cursor-pointer rounded-[3px] border border-border p-0 transition-shadow hover:ring-1 hover:ring-primary focus:outline-none focus:ring-2 focus:ring-primary"
style={{ background: color }}
onClick={handleClick}
onKeyDown={handleKeyDown}
title="Open color picker"
aria-label={`Color: ${color}. Click to open color picker`}
data-testid="color-swatch"
>
<input
ref={inputRef}
type="color"
value={hex}
onChange={handleChange}
className="pointer-events-none absolute inset-0 opacity-0"
tabIndex={-1}
aria-hidden="true"
data-testid="color-picker-input"
/>
</button>
</span>
)
}
/**
* Editable text field with an inline color swatch.
* The swatch only appears when the value is a valid CSS color.
*/
export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCancel }: {
value: string
isEditing: boolean
onStartEdit: () => void
onSave: (newValue: string) => void
onCancel: () => void
}) {
const [editValue, setEditValue] = useState(value)
const showSwatch = isValidCssColor(isEditing ? editValue : value)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') onSave(editValue)
else if (e.key === 'Escape') { setEditValue(value); onCancel() }
}
const handlePickerChange = useCallback((hex: string) => {
if (isEditing) {
setEditValue(hex)
}
onSave(hex)
}, [isEditing, onSave])
if (isEditing) {
return (
<span className="flex w-full items-center gap-1.5">
{showSwatch && <ColorSwatch color={editValue} onChange={handlePickerChange} />}
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSave(editValue)}
autoFocus
data-testid="color-text-input"
/>
</span>
)
}
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />}
<span
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>
{value || '\u2014'}
</span>
</span>
)
}

View File

@@ -165,4 +165,64 @@ describe('CommandPalette', () => {
expect(screen.getByText('↵ select')).toBeInTheDocument()
expect(screen.getByText('esc close')).toBeInTheDocument()
})
describe('relevance ranking', () => {
const relevanceCommands: CommandAction[] = [
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note' }),
makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }),
makeCommand({ id: 'switch-theme', label: 'Switch Theme', group: 'Appearance', keywords: ['dark', 'light'] }),
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }),
]
function getVisibleLabels() {
return screen.getAllByText(
(_content, el) =>
el?.tagName === 'SPAN' &&
el.classList.contains('text-foreground') &&
!!el.textContent,
).map(el => el.textContent)
}
it('ranks "Toggle Raw Editor" before "Create New Note" for query "raw"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'raw' } })
const labels = getVisibleLabels()
const rawIdx = labels.indexOf('Toggle Raw Editor')
const createIdx = labels.indexOf('Create New Note')
expect(rawIdx).toBeGreaterThanOrEqual(0)
expect(createIdx).toBeGreaterThanOrEqual(0)
expect(rawIdx).toBeLessThan(createIdx)
})
it('ranks "Create New Note" first for query "new note"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'new note' } })
const labels = getVisibleLabels()
expect(labels[0]).toBe('Create New Note')
})
it('ranks theme commands first for query "theme"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'theme' } })
const labels = getVisibleLabels()
expect(labels[0]).toBe('Switch Theme')
})
it('preserves default section order with empty query', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
const groupHeaders = screen.getAllByText(
(_content, el) =>
el?.tagName === 'DIV' &&
el.classList.contains('uppercase') &&
!!el.textContent,
).map(el => el.textContent)
// Default order: Navigation < Note < View < Appearance
expect(groupHeaders).toEqual(['Navigation', 'Note', 'View', 'Appearance'])
})
})
})

View File

@@ -30,16 +30,21 @@ function matchCommand(query: string, cmd: CommandAction): ScoredCommand | null {
return null
}
function groupResults(commands: CommandAction[]): { group: CommandGroup; items: CommandAction[] }[] {
function groupResults(
commands: CommandAction[],
byRelevance: boolean,
): { group: CommandGroup; items: CommandAction[] }[] {
const map = new Map<CommandGroup, CommandAction[]>()
for (const cmd of commands) {
const list = map.get(cmd.group)
if (list) list.push(cmd)
else map.set(cmd.group, [cmd])
}
return Array.from(map.entries())
.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
.map(([group, items]) => ({ group, items }))
const entries = Array.from(map.entries())
if (!byRelevance) {
entries.sort((a, b) => groupSortKey(a[0]) - groupSortKey(b[0]))
}
return entries.map(([group, items]) => ({ group, items }))
}
export function CommandPalette({ open, commands, onClose }: CommandPaletteProps) {
@@ -70,7 +75,8 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
.map(r => r.command)
}, [enabledCommands, query])
const groups = useMemo(() => groupResults(filtered), [filtered])
const hasQuery = !!query.trim()
const groups = useMemo(() => groupResults(filtered, hasQuery), [filtered, hasQuery])
const flatList = useMemo(() => groups.flatMap(g => g.items), [groups])
useEffect(() => {

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ConflictResolverModal } from './ConflictResolverModal'
import type { ConflictFileState } from '../hooks/useConflictResolver'
function makeFileStates(overrides?: Partial<ConflictFileState>[]): ConflictFileState[] {
const defaults: ConflictFileState[] = [
{ file: 'notes/project.md', resolution: null, resolving: false },
{ file: 'notes/plan.md', resolution: null, resolving: false },
]
if (!overrides) return defaults
return defaults.map((d, i) => ({ ...d, ...(overrides[i] ?? {}) }))
}
function renderModal(overrides: Record<string, unknown> = {}) {
const props = {
open: true,
fileStates: makeFileStates(),
allResolved: false,
committing: false,
error: null,
onResolveFile: vi.fn(),
onOpenInEditor: vi.fn(),
onCommit: vi.fn(),
onClose: vi.fn(),
...overrides,
}
return { ...render(<ConflictResolverModal {...props} />), props }
}
describe('ConflictResolverModal', () => {
it('renders conflict file list when open', () => {
renderModal()
expect(screen.getByText('Resolve Merge Conflicts')).toBeInTheDocument()
expect(screen.getByTestId('conflict-file-list')).toBeInTheDocument()
expect(screen.getByTestId('conflict-file-notes/project.md')).toBeInTheDocument()
expect(screen.getByTestId('conflict-file-notes/plan.md')).toBeInTheDocument()
})
it('shows file count description', () => {
renderModal()
expect(screen.getByText(/2 files have merge conflicts/)).toBeInTheDocument()
})
it('shows singular description for one file', () => {
renderModal({ fileStates: [{ file: 'note.md', resolution: null, resolving: false }] })
expect(screen.getByText(/1 file has merge conflicts/)).toBeInTheDocument()
})
it('calls onResolveFile when Keep mine is clicked', () => {
const { props } = renderModal()
fireEvent.click(screen.getByTestId('resolve-ours-notes/project.md'))
expect(props.onResolveFile).toHaveBeenCalledWith('notes/project.md', 'ours')
})
it('calls onResolveFile when Keep theirs is clicked', () => {
const { props } = renderModal()
fireEvent.click(screen.getByTestId('resolve-theirs-notes/plan.md'))
expect(props.onResolveFile).toHaveBeenCalledWith('notes/plan.md', 'theirs')
})
it('calls onOpenInEditor when Open in editor is clicked', () => {
const { props } = renderModal()
fireEvent.click(screen.getByTestId('resolve-open-notes/project.md'))
expect(props.onOpenInEditor).toHaveBeenCalledWith('notes/project.md')
})
it('hides Open in editor for binary files', () => {
renderModal({
fileStates: [{ file: 'images/photo.png', resolution: null, resolving: false }],
})
expect(screen.queryByTestId('resolve-open-images/photo.png')).not.toBeInTheDocument()
// Keep mine/theirs should still be visible
expect(screen.getByTestId('resolve-ours-images/photo.png')).toBeInTheDocument()
})
it('Commit & continue button is disabled when not all resolved', () => {
renderModal({ allResolved: false })
expect(screen.getByTestId('conflict-commit-btn')).toBeDisabled()
})
it('Commit & continue button is enabled when all resolved', () => {
renderModal({ allResolved: true })
expect(screen.getByTestId('conflict-commit-btn')).toBeEnabled()
})
it('calls onCommit when Commit & continue is clicked', () => {
const { props } = renderModal({ allResolved: true })
fireEvent.click(screen.getByTestId('conflict-commit-btn'))
expect(props.onCommit).toHaveBeenCalled()
})
it('shows committing state', () => {
renderModal({ allResolved: true, committing: true })
expect(screen.getByText('Committing…')).toBeInTheDocument()
expect(screen.getByTestId('conflict-commit-btn')).toBeDisabled()
})
it('shows error message', () => {
renderModal({ error: 'git commit failed: user.email not set' })
expect(screen.getByTestId('conflict-error')).toHaveTextContent('git commit failed')
})
it('shows resolution labels after file is resolved', () => {
renderModal({
fileStates: makeFileStates([{ resolution: 'ours' }, { resolution: 'theirs' }]),
})
expect(screen.getByText('Keeping mine')).toBeInTheDocument()
expect(screen.getByText('Keeping theirs')).toBeInTheDocument()
})
it('shows spinner when file is resolving', () => {
renderModal({
fileStates: [{ file: 'note.md', resolution: null, resolving: true }],
})
// Buttons hidden during resolving, spinner shown instead
expect(screen.queryByTestId('resolve-ours-note.md')).not.toBeInTheDocument()
})
it('calls onClose when Cancel is clicked', () => {
const { props } = renderModal()
fireEvent.click(screen.getByText('Cancel'))
expect(props.onClose).toHaveBeenCalled()
})
it('shows keyboard shortcut hint', () => {
renderModal()
expect(screen.getByText(/K = keep mine/)).toBeInTheDocument()
})
})

View File

@@ -2,20 +2,8 @@ import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel'
import type { VaultEntry } from '../types'
// Mock localStorage (jsdom's may be incomplete)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (i: number) => Object.keys(store)[i] ?? null,
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
import { initDisplayModeOverrides } from '../utils/propertyTypes'
// Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM
beforeAll(() => {
@@ -847,7 +835,12 @@ describe('DynamicPropertiesPanel', () => {
describe('display mode override', () => {
beforeEach(() => {
localStorageMock.clear()
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null },
vi.fn(),
)
initDisplayModeOverrides({})
})
it('renders display mode trigger on property rows', () => {
@@ -880,7 +873,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByTestId('display-mode-option-url')).toBeInTheDocument()
})
it('persists override to localStorage when mode selected', () => {
it('persists override to vault config when mode selected', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -891,12 +884,13 @@ describe('DynamicPropertiesPanel', () => {
)
fireEvent.click(screen.getByTestId('display-mode-trigger'))
fireEvent.click(screen.getByTestId('display-mode-option-status'))
const stored = JSON.parse(localStorageMock.getItem('laputa:display-mode-overrides') ?? '{}')
const stored = getVaultConfig().property_display_modes as Record<string, string>
expect(stored).toBeTruthy()
expect(stored.cadence).toBe('status')
})
it('overrides rendering to status badge when status mode selected', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ cadence: 'status' }))
initDisplayModeOverrides({ cadence: 'status' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -909,7 +903,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders boolean toggle for string "true" when boolean mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
initDisplayModeOverrides({ draft: 'boolean' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -923,7 +917,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders boolean toggle for string "false" when boolean mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
initDisplayModeOverrides({ draft: 'boolean' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -937,7 +931,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('toggles string boolean from false to true', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
initDisplayModeOverrides({ draft: 'boolean' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -951,7 +945,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders date picker for empty value when date mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ due: 'date' }))
initDisplayModeOverrides({ due: 'date' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -965,7 +959,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders date picker for non-date string when date mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ deadline: 'date' }))
initDisplayModeOverrides({ deadline: 'date' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}

View File

@@ -1,17 +1,18 @@
import { useMemo, useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { parseFrontmatter } from '../utils/frontmatter'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { usePropertyPanelState } from '../hooks/usePropertyPanelState'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { isValidCssColor } from '../utils/colorUtils'
import { getTypeIcon } from './NoteItem'
import { countWords } from '../utils/wikilinks'
import {
@@ -19,14 +20,12 @@ import {
getEffectiveDisplayMode,
formatDateValue,
toISODate,
loadDisplayModeOverrides,
saveDisplayModeOverride,
removeDisplayModeOverride,
detectPropertyType,
} from '../utils/propertyTypes'
import { StatusPill, StatusDropdown } from './StatusDropdown'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
// Keys that are relationships (contain wikilinks)
export const RELATIONSHIP_KEYS = new Set([
@@ -34,9 +33,6 @@ export const RELATIONSHIP_KEYS = new Set([
'Advances', 'Parent', 'Children', 'Has', 'Notes',
])
// Keys to skip showing in Properties (handled by dedicated UI or internal)
const SKIP_KEYS = new Set(['aliases', 'notion_id', 'workspace', 'title', 'type', 'is_a', 'Is A'])
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function containsWikilinks(value: FrontmatterValue): boolean {
if (typeof value === 'string') return /^\[\[.*\]\]$/.test(value)
@@ -51,19 +47,6 @@ function formatDate(timestamp: number | null): string {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
function coerceValue(raw: string): FrontmatterValue {
if (raw.toLowerCase() === 'true') return true
if (raw.toLowerCase() === 'false') return false
if (!isNaN(Number(raw)) && raw.trim() !== '') return Number(raw)
return raw
}
function parseNewValue(rawValue: string): FrontmatterValue {
if (!rawValue.includes(',')) return rawValue.trim() || ''
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
return items.length === 1 ? items[0] : items
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
@@ -253,6 +236,7 @@ const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
{ value: 'status', label: 'Status' },
{ value: 'url', label: 'URL' },
{ value: 'tags', label: 'Tags' },
{ value: 'color', label: 'Color' },
]
function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
@@ -331,7 +315,7 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
}
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag,
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
}
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
@@ -565,6 +549,7 @@ function toBooleanValue(value: FrontmatterValue): boolean {
function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
if (typeof value === 'boolean') return 'boolean'
if (typeof value === 'string' && isUrlValue(value)) return 'url'
if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color'
return 'text'
}
@@ -591,6 +576,8 @@ function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses
}
case 'url':
return <UrlValue {...editProps} />
case 'color':
return <ColorEditableValue {...editProps} />
default:
return <EditableValue {...editProps} />
}
@@ -670,134 +657,6 @@ function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: n
)
}
function reconcileListUpdate(
newItems: string[],
onUpdate: (key: string, value: FrontmatterValue) => void,
onDelete: ((key: string) => void) | undefined,
key: string,
) {
if (newItems.length === 0) onDelete?.(key)
else if (newItems.length === 1) onUpdate(key, newItems[0])
else onUpdate(key, newItems)
}
function deriveTypeInfo(entries: VaultEntry[] | undefined, entryIsA: string | null) {
const typeEntries = (entries ?? []).filter(e => e.isA === 'Type')
const typeColorKeys: Record<string, string | null> = {}
const typeIconKeys: Record<string, string | null> = {}
for (const e of typeEntries) {
typeColorKeys[e.title] = e.color ?? null
typeIconKeys[e.title] = e.icon ?? null
}
return {
availableTypes: typeEntries.map(e => e.title).sort((a, b) => a.localeCompare(b)),
customColorKey: entryIsA ? (typeColorKeys[entryIsA] ?? null) : null,
typeColorKeys,
typeIconKeys,
}
}
function collectVaultStatuses(entries: VaultEntry[] | undefined): string[] {
const seen = new Set<string>()
for (const e of entries ?? []) {
if (e.status) seen.add(e.status)
}
return Array.from(seen).sort((a, b) => a.localeCompare(b))
}
function mergeArrayFieldsInto(fm: ParsedFrontmatter, tagsByKey: Map<string, Set<string>>): void {
for (const [key, value] of Object.entries(fm)) {
if (!Array.isArray(value)) continue
let set = tagsByKey.get(key)
if (!set) { set = new Set(); tagsByKey.set(key, set) }
for (const tag of value) set.add(String(tag))
}
}
function collectAllVaultTags(entries: VaultEntry[] | undefined, allContent: Record<string, string> | undefined): Record<string, string[]> {
if (!entries || !allContent) return {}
const tagsByKey = new Map<string, Set<string>>()
for (const entry of entries) {
const content = allContent[entry.path]
if (!content) continue
mergeArrayFieldsInto(parseFrontmatter(content), tagsByKey)
}
const result: Record<string, string[]> = {}
for (const [key, set] of tagsByKey) result[key] = Array.from(set).sort((a, b) => a.localeCompare(b))
return result
}
function isVisibleProperty([key, value]: [string, FrontmatterValue]): boolean {
return !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value)
}
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
if (mode === 'tags') {
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
return items
}
return parseNewValue(rawValue)
}
function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
if (mode === null) removeDisplayModeOverride(key)
else saveDisplayModeOverride(key, mode)
}
interface PropertyPanelDeps {
entries: VaultEntry[] | undefined
entryIsA: string | null
frontmatter: ParsedFrontmatter
allContent: Record<string, string> | undefined
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
}
function usePropertyPanelState(deps: PropertyPanelDeps) {
const { entries, entryIsA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty } = deps
const [editingKey, setEditingKey] = useState<string | null>(null)
const [showAddDialog, setShowAddDialog] = useState(false)
const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides())
const { availableTypes, customColorKey, typeColorKeys, typeIconKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries, allContent), [entries, allContent])
const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter])
const handleSaveValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
if (onUpdateProperty) onUpdateProperty(key, coerceValue(newValue))
}, [onUpdateProperty])
const handleSaveList = useCallback((key: string, newItems: string[]) => {
if (!onUpdateProperty) return
reconcileListUpdate(newItems, onUpdateProperty, onDeleteProperty, key)
}, [onUpdateProperty, onDeleteProperty])
const handleAdd = useCallback((rawKey: string, rawValue: string, mode: PropertyDisplayMode) => {
if (!rawKey.trim() || !onAddProperty) return
onAddProperty(rawKey.trim(), parseAddedValue(rawValue, mode))
if (mode !== 'text') {
persistModeOverride(rawKey.trim(), mode)
setDisplayOverrides(loadDisplayModeOverrides())
}
setShowAddDialog(false)
}, [onAddProperty])
const handleDisplayModeChange = useCallback((key: string, mode: PropertyDisplayMode | null) => {
persistModeOverride(key, mode)
setDisplayOverrides(loadDisplayModeOverrides())
}, [])
return {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
}
}
export function DynamicPropertiesPanel({
entry, content, frontmatter, entries, allContent,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,

View File

@@ -5,6 +5,7 @@ import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
import { uploadImageFile } from '../hooks/useImageDrop'
import type { VaultEntry, GitCommit, NoteStatus } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import type { FrontmatterValue } from './Inspector'
import { ResizeHandle } from './ResizeHandle'
import { TabBar } from './TabBar'
@@ -48,6 +49,8 @@ interface EditorProps {
showAIChat?: boolean
onToggleAIChat?: () => void
vaultPath?: string
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onDeleteNote?: (path: string) => void
@@ -68,6 +71,8 @@ interface EditorProps {
rawToggleRef?: React.MutableRefObject<() => void>
/** Mutable ref that Editor registers its diff-mode toggle into, for command palette access. */
diffToggleRef?: React.MutableRefObject<() => void>
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
}
function useEditorModeExclusion({
@@ -117,13 +122,15 @@ export const Editor = memo(function Editor({
inspectorEntry, inspectorContent, allContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
showAIChat, onToggleAIChat,
vaultPath,
vaultPath, noteList, noteListFilter,
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange, onSave, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
isDarkTheme,
rawToggleRef,
diffToggleRef,
onFileCreated,
onFileModified,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -228,6 +235,9 @@ export const Editor = memo(function Editor({
allContent={allContent}
gitHistory={gitHistory}
vaultPath={vaultPath ?? ''}
openTabs={tabs.map(t => t.entry)}
noteList={noteList}
noteListFilter={noteListFilter}
onToggleInspector={onToggleInspector}
onToggleAIChat={onToggleAIChat}
onNavigateWikilink={onNavigateWikilink}
@@ -236,6 +246,8 @@ export const Editor = memo(function Editor({
onDeleteProperty={onDeleteProperty}
onAddProperty={onAddProperty}
onOpenNote={onNavigateWikilink}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
/>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import type { VaultEntry, GitCommit } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import { Inspector, type FrontmatterValue } from './Inspector'
import { AiPanel } from './AiPanel'
@@ -12,6 +13,9 @@ interface EditorRightPanelProps {
allContent: Record<string, string>
gitHistory: GitCommit[]
vaultPath: string
openTabs?: VaultEntry[]
noteList?: NoteListItem[]
noteListFilter?: { type: string | null; query: string }
onToggleInspector: () => void
onToggleAIChat?: () => void
onNavigateWikilink: (target: string) => void
@@ -20,13 +24,17 @@ interface EditorRightPanelProps {
onDeleteProperty?: (path: string, key: string) => Promise<void>
onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise<void>
onOpenNote?: (path: string) => void
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
}
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath,
inspectorEntry, inspectorContent, entries, allContent, gitHistory, vaultPath, openTabs,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
onFileCreated, onFileModified,
}: EditorRightPanelProps) {
if (showAIChat) {
return (
@@ -37,10 +45,15 @@ export function EditorRightPanel({
<AiPanel
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
onFileCreated={onFileCreated}
onFileModified={onFileModified}
vaultPath={vaultPath}
activeEntry={inspectorEntry}
entries={entries}
allContent={allContent}
openTabs={openTabs}
noteList={noteList}
noteListFilter={noteListFilter}
/>
</div>
)

View File

@@ -1,538 +1,6 @@
import { useMemo, useCallback, useState, useRef } from 'react'
import type { ComponentType, SVGAttributes } from 'react'
import { wikilinkTarget, wikilinkDisplay } from '../utils/wikilink'
import type { VaultEntry, GitCommit } from '../types'
import { CaretRight, Trash, X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import { RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { findEntryByTarget } from '../utils/wikilinkColors'
import type { FrontmatterValue } from './Inspector'
import { NoteSearchList } from './NoteSearchList'
import { useNoteSearch } from '../hooks/useNoteSearch'
function isWikilink(value: string): boolean {
return /^\[\[.*\]\]$/.test(value)
}
function resolveRef(ref: string, entries: VaultEntry[]): VaultEntry | undefined {
const target = wikilinkTarget(ref)
const byTitle = findEntryByTarget(entries, target)
if (byTitle) return byTitle
const lastSegment = target.split('/').pop()
return entries.find((e) => {
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (stem === target) return true
return e.filename.replace(/\.md$/, '') === lastSegment
})
}
function StatusSuffix({ isArchived, isTrashed }: { isArchived: boolean; isTrashed: boolean }) {
if (isTrashed) return <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>
if (isArchived) return <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>
return null
}
function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: {
label: string
typeColor: string
bgColor?: string
isArchived: boolean
isTrashed: boolean
onClick: () => void
onRemove?: () => void
title?: string
TypeIcon: ComponentType<SVGAttributes<SVGSVGElement>>
}) {
const isDimmed = isArchived || isTrashed
const color = isDimmed ? 'var(--muted-foreground)' : typeColor
return (
<button
className={`group/link flex w-full items-center justify-between gap-2 border-none text-left cursor-pointer min-w-0${bgColor ? ' ring-inset hover:ring-1 hover:ring-current' : ' hover:opacity-80'}`}
style={{
background: isDimmed ? 'var(--muted)' : (bgColor ?? 'transparent'),
color, borderRadius: 6, padding: bgColor ? '6px 10px' : '4px 0',
fontSize: 12, fontWeight: 500, opacity: isDimmed ? 0.7 : 1,
}}
onClick={onClick}
title={title}
>
<span className="flex items-center gap-1 flex-1 truncate">
{isTrashed && <Trash size={12} className="shrink-0" />}
{label}
<StatusSuffix isArchived={isArchived} isTrashed={isTrashed} />
</span>
<span className="flex items-center gap-1.5 shrink-0">
{onRemove && (
<span
className="flex items-center opacity-0 transition-opacity group-hover/link:opacity-100"
onClick={(e) => { e.stopPropagation(); onRemove() }}
role="button"
title="Remove from relation"
data-testid="remove-relation-ref"
>
<X size={14} />
</span>
)}
<TypeIcon width={14} height={14} className="shrink-0" style={{ color, opacity: 0.5 }} />
</span>
</button>
)
}
function entryStatusTitle(entry: VaultEntry | undefined): string | undefined {
if (entry?.trashed) return 'Trashed'
if (entry?.archived) return 'Archived'
return undefined
}
function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>) {
const resolved = resolveRef(ref, entries)
const refType = resolved?.isA ?? null
const te = typeEntryMap[refType ?? '']
return {
label: wikilinkDisplay(ref),
typeColor: getTypeColor(refType, te?.color),
bgColor: getTypeLightColor(refType, te?.color),
isArchived: resolved?.archived ?? false,
isTrashed: resolved?.trashed ?? false,
target: wikilinkTarget(ref),
title: entryStatusTitle(resolved),
TypeIcon: getTypeIcon(refType, te?.icon),
}
}
function SearchDropdown({ search, onSelect }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (title: string) => void
}) {
return (
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
<NoteSearchList
items={search.results}
selectedIndex={search.selectedIndex}
getItemKey={(item) => item.entry.path}
onItemClick={(item) => onSelect(item.entry.title)}
onItemHover={(i) => search.setSelectedIndex(i)}
className="max-h-[160px] overflow-y-auto"
/>
</div>
)
}
function InlineAddNote({ entries, onAdd }: {
entries: VaultEntry[]
onAdd: (noteTitle: string) => void
}) {
const [active, setActive] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const search = useNoteSearch(entries, query, 8)
const selectAndClose = useCallback((title: string) => {
onAdd(title)
setQuery('')
setActive(false)
}, [onAdd])
const handleConfirm = useCallback(() => {
const title = search.selectedEntry?.title ?? query.trim()
if (title) selectAndClose(title)
}, [search.selectedEntry, query, selectAndClose])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
search.handleKeyDown(e)
if (e.key === 'Enter') { e.preventDefault(); handleConfirm() }
else if (e.key === 'Escape') { setQuery(''); setActive(false) }
}, [search, handleConfirm])
if (!active) {
return (
<button
className="mt-1 w-full border border-dashed border-border bg-transparent text-left text-muted-foreground cursor-pointer hover:border-foreground hover:text-foreground"
style={{ borderRadius: 6, padding: '6px 10px', fontSize: 12 }}
onClick={() => setActive(true)}
data-testid="add-relation-ref"
>
Add
</button>
)
}
return (
<div className="relative mt-1">
<div className="group/add relative flex items-center">
<input
ref={inputRef}
autoFocus
className="w-full border border-border bg-transparent text-foreground"
style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }}
placeholder="Note title"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="add-relation-ref-input"
/>
<button
className="absolute right-1 top-1/2 -translate-y-1/2 border-none bg-transparent p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/add:opacity-100"
onClick={() => { setQuery(''); setActive(false) }}
>
<X size={12} />
</button>
</div>
{query.trim() && search.results.length > 0 && (
<SearchDropdown search={search} onSelect={selectAndClose} />
)}
</div>
)
}
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
onRemoveRef?: (ref: string) => void; onAddRef?: (noteTitle: string) => void
}) {
if (refs.length === 0) return null
return (
<div className="mb-2.5">
<span className="font-mono-overline mb-1 block text-muted-foreground">{label}</span>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => {
const props = resolveRefProps(ref, entries, typeEntryMap)
return (
<LinkButton
key={`${ref}-${idx}`}
{...props}
onClick={() => onNavigate(props.target)}
onRemove={onRemoveRef ? () => onRemoveRef(ref) : undefined}
/>
)
})}
</div>
{onAddRef && <InlineAddNote entries={entries} onAdd={onAddRef} />}
</div>
)
}
function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] {
return Object.entries(frontmatter)
.filter(([key, value]) => key !== 'Type' && (RELATIONSHIP_KEYS.has(key) || containsWikilinks(value)))
.map(([key, value]) => {
const refs: string[] = []
if (typeof value === 'string' && isWikilink(value)) refs.push(value)
else if (Array.isArray(value)) value.forEach(v => { if (typeof v === 'string' && isWikilink(v)) refs.push(v) })
return { key, refs }
})
.filter(({ refs }) => refs.length > 0)
}
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
entries: VaultEntry[]
value: string
onChange: (v: string) => void
onSubmit?: () => void
onCancel?: () => void
}) {
const [focused, setFocused] = useState(false)
const search = useNoteSearch(entries, value, 8)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
search.handleKeyDown(e)
if (e.key === 'Enter') {
e.preventDefault()
if (search.selectedEntry) { onChange(search.selectedEntry.title); setFocused(false) }
else onSubmit?.()
} else if (e.key === 'Escape') { onCancel?.() }
}, [search, onChange, onSubmit, onCancel])
const showDropdown = focused && value.trim() && search.results.length > 0
return (
<div className="relative">
<input
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Note title"
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 150)}
onKeyDown={handleKeyDown}
/>
{showDropdown && (
<SearchDropdown search={search} onSelect={(title) => { onChange(title); setFocused(false) }} />
)}
</div>
)
}
function AddRelationshipForm({ entries, onAddProperty }: {
entries: VaultEntry[]
onAddProperty: (key: string, value: FrontmatterValue) => void
}) {
const [relKey, setRelKey] = useState('')
const [relTarget, setRelTarget] = useState('')
const [showForm, setShowForm] = useState(false)
const keyInputRef = useRef<HTMLInputElement>(null)
const submitForm = useCallback((targetOverride?: string) => {
const key = relKey.trim()
const target = (targetOverride ?? relTarget).trim()
if (!key || !target) return
onAddProperty(key, `[[${target}]]`)
setRelKey(''); setRelTarget(''); setShowForm(false)
}, [relKey, relTarget, onAddProperty])
const resetForm = useCallback(() => {
setShowForm(false); setRelKey(''); setRelTarget('')
}, [])
if (!showForm) {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer' }} onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}>+ Link existing</button>
)
}
return (
<div className="mt-2 flex flex-col gap-1.5">
<input
ref={keyInputRef}
autoFocus
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Relationship name"
value={relKey}
onChange={e => setRelKey(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }}
/>
<NoteTargetInput entries={entries} value={relTarget} onChange={setRelTarget} onSubmit={submitForm} onCancel={resetForm} />
<div className="flex gap-1.5">
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
</div>
</div>
)
}
function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterValue | null {
const remaining = refs.filter(r => r !== refToRemove)
if (remaining.length === 0) return null
return remaining.length === 1 ? remaining[0] : remaining
}
function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false {
const newRef = `[[${noteTitle}]]`
if (refs.includes(newRef)) return false
const updated = [...refs, newRef]
return updated.length === 1 ? updated[0] : updated
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
}) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
if (!onUpdateProperty || !onDeleteProperty) return
const group = relationshipEntries.find(g => g.key === key)
if (!group) return
const result = updateRefsForRemoval(group.refs, refToRemove)
if (result === null) onDeleteProperty(key)
else onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
const handleAddRef = useCallback((key: string, noteTitle: string) => {
if (!onUpdateProperty) return
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
const result = updateRefsForAddition(existing, noteTitle)
if (result !== false) onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty])
const canEdit = !!onUpdateProperty && !!onDeleteProperty
return (
<div>
{relationshipEntries.map(({ key, refs }) => (
<RelationshipGroup
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
/>
))}
{onAddProperty
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} />
: <button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
}
</div>
)
}
export interface BacklinkItem {
entry: VaultEntry
context: string | null
}
function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
entry: VaultEntry
context: string | null
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const te = typeEntryMap[entry.isA ?? '']
const isDimmed = entry.archived || entry.trashed
return (
<button
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:opacity-80"
onClick={() => onNavigate(entry.title)}
title={entryStatusTitle(entry)}
>
<span
className="flex items-center gap-1 text-xs font-medium"
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
>
{entry.trashed && <Trash size={12} className="shrink-0" />}
{entry.title}
<StatusSuffix isArchived={entry.archived} isTrashed={entry.trashed} />
</span>
{context && (
<span className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{context}
</span>
)}
</button>
)
}
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: {
backlinks: BacklinkItem[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const [expanded, setExpanded] = useState(false)
if (backlinks.length === 0) return null
return (
<div>
<button
className="font-mono-overline mb-2 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
data-testid="backlinks-toggle"
>
<CaretRight
size={12}
className="shrink-0 transition-transform"
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
/>
Backlinks ({backlinks.length})
</button>
{expanded && (
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
{backlinks.map(({ entry, context }) => (
<BacklinkEntry
key={entry.path}
entry={entry}
context={context}
typeEntryMap={typeEntryMap}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}
export interface ReferencedByItem {
entry: VaultEntry
viaKey: string
}
export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
items: ReferencedByItem[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const grouped = useMemo(() => {
const map = new Map<string, VaultEntry[]>()
for (const item of items) {
const existing = map.get(item.viaKey)
if (existing) existing.push(item.entry)
else map.set(item.viaKey, [item.entry])
}
return Array.from(map.entries())
}, [items])
if (items.length === 0) return null
return (
<div className="referenced-by-panel">
<div className="flex flex-col gap-2.5">
{grouped.map(([viaKey, groupEntries]) => (
<div key={viaKey}>
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
{viaKey}
</span>
<div className="flex flex-col gap-0.5">
{groupEntries.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton
key={e.path}
label={e.title}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
isTrashed={e.trashed}
onClick={() => onNavigate(e.title)}
title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
</div>
</div>
))}
</div>
</div>
)
}
function formatRelativeDate(timestamp: number): string {
const now = Math.floor(Date.now() / 1000)
const days = Math.floor((now - timestamp) / 86400)
if (days < 1) return 'today'
if (days === 1) return 'yesterday'
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
return months === 1 ? '1mo ago' : `${months}mo ago`
}
export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) {
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">History</h4>
{commits.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
: (
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<div className="mb-0.5 flex items-center justify-between">
<button className="border-none bg-transparent p-0 font-mono text-primary cursor-pointer hover:underline" style={{ fontSize: 11 }} onClick={() => onViewCommitDiff?.(c.hash)} title={`View diff for ${c.shortHash}`}>{c.shortHash}</button>
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
</div>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
{c.author && <div className="truncate text-muted-foreground" style={{ fontSize: 10, marginTop: 1 }}>{c.author}</div>}
</div>
))}
</div>
)
}
</div>
)
}
export { DynamicRelationshipsPanel } from './inspector/RelationshipsPanel'
export { BacklinksPanel } from './inspector/BacklinksPanel'
export type { BacklinkItem } from './inspector/BacklinksPanel'
export { ReferencedByPanel } from './inspector/ReferencedByPanel'
export type { ReferencedByItem } from './inspector/ReferencedByPanel'
export { GitHistoryPanel } from './inspector/GitHistoryPanel'

View File

@@ -10,7 +10,7 @@ import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typ
import { NoteItem, getTypeIcon } from './NoteItem'
import { SortDropdown } from './SortDropdown'
import { BulkActionBar } from './BulkActionBar'
import { useMultiSelect } from '../hooks/useMultiSelect'
import { useMultiSelect, type MultiSelectState } from '../hooks/useMultiSelect'
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
import {
type SortOption, type SortDirection, type SortConfig, type RelationshipGroup,
@@ -148,13 +148,20 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, query: string): string {
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
if (isChangesView) return 'No pending changes'
if (isTrashView) return 'Trash is empty'
return query ? 'No matching notes' : 'No notes found'
}
function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = (isChangesView && changesError) ? `Failed to load changes: ${changesError}` : isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, query)
const hasHeader = isTrashView && expiredTrashCount > 0
if (searched.length === 0) {
@@ -198,17 +205,16 @@ function countExpiredTrash(entries: VaultEntry[]): number {
// --- Click routing ---
type MultiSelectActions = { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
interface ClickActions {
onReplace: (entry: VaultEntry) => void
onSelect: (entry: VaultEntry) => void
multiSelect: { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
}
function routeNoteClick(
entry: VaultEntry, e: React.MouseEvent,
onReplaceActiveTab: (entry: VaultEntry) => void,
onSelectNote: (entry: VaultEntry) => void,
multiSelect: MultiSelectActions,
) {
if (e.shiftKey) { multiSelect.selectRange(entry.path) }
else if (e.metaKey || e.ctrlKey) { multiSelect.clear(); onSelectNote(entry) }
else { multiSelect.clear(); multiSelect.setAnchor(entry.path); onReplaceActiveTab(entry) }
function routeNoteClick(entry: VaultEntry, e: React.MouseEvent, actions: ClickActions) {
if (e.shiftKey) { actions.multiSelect.selectRange(entry.path) }
else if (e.metaKey || e.ctrlKey) { actions.multiSelect.clear(); actions.onSelect(entry) }
else { actions.multiSelect.clear(); actions.multiSelect.setAnchor(entry.path); actions.onReplace(entry) }
}
// --- Pure helpers extracted from NoteListInner to reduce cyclomatic complexity ---
@@ -292,170 +298,162 @@ function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Recor
return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG
}
// --- Main component ---
// --- Extracted hooks ---
const defaultGetNoteStatus = (): NoteStatus => 'clean'
interface SortPersistence {
onUpdateTypeSort: (path: string, key: string, value: string) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
}
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
function persistSortToType(path: string, config: SortConfig, persistence: SortPersistence) {
const serialized = serializeSortConfig(config)
persistence.onUpdateTypeSort(path, 'sort', serialized)
persistence.updateEntry(path, { sort: serialized })
clearListSortFromLocalStorage()
}
function migrateListSortToType(typeDoc: VaultEntry, sortPrefs: Record<string, SortConfig>, migrationDone: Set<string>, persistence: SortPersistence) {
if (typeDoc.sort || migrationDone.has(typeDoc.path)) return
const lsConfig = sortPrefs['__list__']
if (!lsConfig) return
migrationDone.add(typeDoc.path)
persistSortToType(typeDoc.path, lsConfig, persistence)
}
function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDirection, setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>) {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
if (!configOption.startsWith('property:')) return configOption
return customProperties.includes(configOption.slice('property:'.length)) ? configOption : 'modified'
}
interface UseNoteListSortParams {
entries: VaultEntry[]
selection: SidebarSelection
modifiedPathSet: Set<string>
modifiedSuffixes: string[]
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const { onMouseDown: onDragMouseDown } = useDragRegion()
const modifiedPathSet = useMemo(
() => new Set((modifiedFiles ?? []).map((f) => f.path)),
[modifiedFiles],
)
// Suffix patterns for cross-machine robustness: if the vault cache carried
// stale absolute paths from another machine, fall back to matching by the
// relative path suffix so the changes view stays in sync with the badge.
const modifiedSuffixes = useMemo(
() => (modifiedFiles ?? []).map((f) => '/' + f.relativePath),
[modifiedFiles],
)
const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>(
() => createNoteStatusResolver(getNoteStatus, modifiedFiles, modifiedPathSet),
[getNoteStatus, modifiedFiles, modifiedPathSet],
)
// Resolve the type document for sectionGroup selections (needs to be above sort logic)
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
// Resolve list sort config: read from type frontmatter for sectionGroup, else localStorage
const listConfig = resolveListSortConfig(typeDocument, sortPrefs)
const persistence = useMemo<SortPersistence | null>(
() => (onUpdateTypeSort && updateEntry) ? { onUpdateTypeSort, updateEntry } : null,
[onUpdateTypeSort, updateEntry],
)
// Silent migration: if type has no sort in frontmatter but localStorage has __list__, migrate it
const migrationDoneRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!typeDocument || typeDocument.sort || !onUpdateTypeSort || !updateEntry) return
if (migrationDoneRef.current.has(typeDocument.path)) return
const lsConfig = sortPrefs['__list__']
if (!lsConfig) return
migrationDoneRef.current.add(typeDocument.path)
const serialized = serializeSortConfig(lsConfig)
onUpdateTypeSort(typeDocument.path, 'sort', serialized)
updateEntry(typeDocument.path, { sort: serialized })
clearListSortFromLocalStorage()
}, [typeDocument, sortPrefs, onUpdateTypeSort, updateEntry])
if (!typeDocument || !persistence) return
migrateListSortToType(typeDocument, sortPrefs, migrationDoneRef.current, persistence)
}, [typeDocument, sortPrefs, persistence])
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
if (groupLabel === '__list__' && typeDocument && onUpdateTypeSort && updateEntry) {
// Persist sort to type file frontmatter
const config: SortConfig = { option, direction }
const serialized = serializeSortConfig(config)
onUpdateTypeSort(typeDocument.path, 'sort', serialized)
updateEntry(typeDocument.path, { sort: serialized })
// Clear old localStorage __list__ entry if present (migration cleanup)
clearListSortFromLocalStorage()
if (groupLabel === '__list__' && typeDocument && persistence) {
persistSortToType(typeDocument.path, { option, direction }, persistence)
} else {
// Relationship group sorts still use localStorage
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
saveGroupSort(groupLabel, option, direction, setSortPrefs)
}
}, [typeDocument, onUpdateTypeSort, updateEntry])
}, [typeDocument, persistence])
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => toggleSetMember(prev, label))
}, [])
const typeEntryMap = useTypeEntryMap(entries)
const query = search.trim().toLowerCase()
// Compute custom properties and derive effective sort before sorting entries
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => {
const opt = listConfig.option
if (!opt.startsWith('property:')) return opt
return customProperties.includes(opt.slice('property:'.length)) ? opt : 'modified'
}, [listConfig.option, customProperties])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const noteListKeyboard = useNoteListKeyboard({
items: searched,
selectedNotePath: selectedNote?.path ?? null,
onOpen: onReplaceActiveTab,
enabled: !isEntityView,
})
return { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument }
}
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
function useNoteListSearch() {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const query = search.trim().toLowerCase()
// Clear multi-select when sidebar selection changes
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
const toggleSearch = useCallback(() => {
setSearchVisible((v) => { if (v) setSearch(''); return !v })
}, [])
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
routeNoteClick(entry, e, onReplaceActiveTab, onSelectNote, multiSelect)
}, [onReplaceActiveTab, onSelectNote, multiSelect])
return { search, setSearch, query, searchVisible, toggleSearch }
}
const handleBulkArchive = useCallback(() => {
const paths = [...multiSelect.selectedPaths]
multiSelect.clear()
onBulkArchive?.(paths)
}, [multiSelect, onBulkArchive])
function isInputFocused(): boolean {
const el = document.activeElement
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || !!(el as HTMLElement)?.isContentEditable
}
const handleBulkTrash = useCallback(() => {
const paths = [...multiSelect.selectedPaths]
multiSelect.clear()
onBulkTrash?.(paths)
}, [multiSelect, onBulkTrash])
function handleEscapeKey(e: KeyboardEvent, multiSelect: MultiSelectState) {
if (e.key !== 'Escape' || !multiSelect.isMultiSelecting) return
e.preventDefault()
multiSelect.clear()
}
// Keyboard: Escape to clear, Cmd+A to select all, Cmd+E/Cmd+Delete for bulk actions
// Uses capture phase so bulk shortcuts preempt the global single-note handler
function handleSelectAllKey(e: KeyboardEvent, multiSelect: MultiSelectState, isEntityView: boolean) {
if (e.key !== 'a' || !(e.metaKey || e.ctrlKey) || isEntityView || isInputFocused()) return
e.preventDefault()
multiSelect.selectAll()
}
function handleBulkActionKey(e: KeyboardEvent, multiSelect: MultiSelectState, onArchive: () => void, onTrash: () => void) {
if (!multiSelect.isMultiSelecting || !(e.metaKey || e.ctrlKey)) return
if (e.key === 'e') { e.preventDefault(); e.stopPropagation(); onArchive() }
if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); e.stopPropagation(); onTrash() }
}
function useMultiSelectKeyboard(multiSelect: MultiSelectState, isEntityView: boolean, onBulkArchive: () => void, onBulkTrash: () => void) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && multiSelect.isMultiSelecting) {
e.preventDefault()
multiSelect.clear()
}
if (e.key === 'a' && (e.metaKey || e.ctrlKey) && !isEntityView) {
const active = document.activeElement
const isInput = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || (active as HTMLElement)?.isContentEditable
if (!isInput) {
e.preventDefault()
multiSelect.selectAll()
}
}
if (multiSelect.isMultiSelecting && (e.metaKey || e.ctrlKey)) {
if (e.key === 'e') {
e.preventDefault()
e.stopPropagation()
handleBulkArchive()
} else if (e.key === 'Backspace' || e.key === 'Delete') {
e.preventDefault()
e.stopPropagation()
handleBulkTrash()
}
}
handleEscapeKey(e, multiSelect)
handleSelectAllKey(e, multiSelect, isEntityView)
handleBulkActionKey(e, multiSelect, onBulkArchive, onBulkTrash)
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [multiSelect, isEntityView, handleBulkArchive, handleBulkTrash])
}, [multiSelect, isEntityView, onBulkArchive, onBulkTrash])
}
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
// --- Header component ---
function NoteListHeader({ title, typeDocument, isEntityView, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange }: {
title: string
typeDocument: VaultEntry | null
isEntityView: boolean
listSort: SortOption
listDirection: SortDirection
customProperties: string[]
sidebarCollapsed?: boolean
searchVisible: boolean
search: string
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
onCreateNote: () => void
onOpenType: (entry: VaultEntry) => void
onToggleSearch: () => void
onSearchChange: (value: string) => void
}) {
const { onMouseDown: onDragMouseDown } = useDragRegion()
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<>
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default', paddingLeft: sidebarCollapsed ? 80 : undefined }}>
<h3
className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold"
style={typeDocument ? { cursor: 'pointer' } : undefined}
onClick={typeDocument ? () => onReplaceActiveTab(typeDocument) : undefined}
onClick={typeDocument ? () => onOpenType(typeDocument) : undefined}
data-testid={typeDocument ? 'type-header-link' : undefined}
>
{resolveHeaderTitle(selection, typeDocument)}
{title}
</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} customProperties={customProperties} onChange={handleSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} customProperties={customProperties} onChange={onSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onToggleSearch} title="Search notes">
<MagnifyingGlass size={16} />
</button>
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => onCreateNote()} title="Create new note">
@@ -463,21 +461,69 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
</button>
</div>
</div>
{searchVisible && (
<div className="border-b border-border px-3 py-2">
<Input placeholder="Search notes..." value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 text-[13px]" autoFocus />
<Input placeholder="Search notes..." value={search} onChange={(e) => onSearchChange(e.target.value)} className="h-8 text-[13px]" autoFocus />
</div>
)}
</>
)
}
// --- Main component ---
const defaultGetNoteStatus = (): NoteStatus => 'clean'
function useModifiedFilesState(modifiedFiles: ModifiedFile[] | undefined, getNoteStatus: ((path: string) => NoteStatus) | undefined) {
const modifiedPathSet = useMemo(() => new Set((modifiedFiles ?? []).map((f) => f.path)), [modifiedFiles])
const modifiedSuffixes = useMemo(() => (modifiedFiles ?? []).map((f) => '/' + f.relativePath), [modifiedFiles])
const resolvedGetNoteStatus = useMemo<(path: string) => NoteStatus>(
() => createNoteStatusResolver(getNoteStatus, modifiedFiles, modifiedPathSet),
[getNoteStatus, modifiedFiles, modifiedPathSet],
)
return { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus }
}
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry })
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const typeEntryMap = useTypeEntryMap(entries)
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const entitySelection = isEntityView && selection.kind === 'entity' ? selection : null
const noteListKeyboard = useNoteListKeyboard({ items: searched, selectedNotePath: selectedNote?.path ?? null, onOpen: onReplaceActiveTab, enabled: !isEntityView })
const multiSelect = useMultiSelect(searched, selectedNote?.path ?? null)
useEffect(() => { multiSelect.clear() }, [selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear on selection change only
const handleClickNote = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
routeNoteClick(entry, e, { onReplace: onReplaceActiveTab, onSelect: onSelectNote, multiSelect })
}, [onReplaceActiveTab, onSelectNote, multiSelect])
const handleBulkArchive = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkArchive?.(paths) }, [multiSelect, onBulkArchive])
const handleBulkTrash = useCallback(() => { const paths = [...multiSelect.selectedPaths]; multiSelect.clear(); onBulkTrash?.(paths) }, [multiSelect, onBulkTrash])
useMultiSelectKeyboard(multiSelect, isEntityView, handleBulkArchive, handleBulkTrash)
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
const title = resolveHeaderTitle(selection, typeDocument)
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={onCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} />
<div className="flex-1 overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
{entitySelection ? (
<EntityView entity={entitySelection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onClear={multiSelect.clear} />
)}

View File

@@ -0,0 +1,240 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { PulseView } from './PulseView'
import type { PulseCommit } from '../types'
const mockCommits: PulseCommit[] = [
{
hash: 'abc123def456',
shortHash: 'abc123d',
message: 'Update project notes',
date: Math.floor(Date.now() / 1000) - 3600,
githubUrl: 'https://github.com/owner/repo/commit/abc123def456',
files: [
{ path: 'project/my-project.md', status: 'modified', title: 'my project' },
{ path: 'note/new-note.md', status: 'added', title: 'new note' },
],
added: 1,
modified: 1,
deleted: 0,
},
{
hash: 'def456abc789',
shortHash: 'def456a',
message: 'Remove old notes',
date: Math.floor(Date.now() / 1000) - 86400,
githubUrl: null,
files: [
{ path: 'note/old.md', status: 'deleted', title: 'old' },
],
added: 0,
modified: 0,
deleted: 1,
},
]
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
describe('PulseView', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows loading state initially', () => {
mockInvokeFn.mockReturnValue(new Promise(() => {})) // never resolves
render(<PulseView vaultPath="/test/vault" />)
expect(screen.getByText('Loading activity…')).toBeInTheDocument()
})
it('renders commits grouped by day', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('Update project notes')).toBeInTheDocument()
})
expect(screen.getByText('Remove old notes')).toBeInTheDocument()
})
it('shows summary badges for added/modified/deleted', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('+1')).toBeInTheDocument()
})
expect(screen.getByText('~1')).toBeInTheDocument()
expect(screen.getByText('-1')).toBeInTheDocument()
})
it('shows commit hashes', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('abc123d')).toBeInTheDocument()
})
expect(screen.getByText('def456a')).toBeInTheDocument()
})
it('renders GitHub links for commits with githubUrl', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
const link = screen.getByText('abc123d')
expect(link.tagName).toBe('A')
expect(link).toHaveAttribute('href', 'https://github.com/owner/repo/commit/abc123def456')
})
// Non-GitHub commit should be a span
const nonLink = screen.getByText('def456a')
expect(nonLink.tagName).toBe('SPAN')
})
it('renders file list with correct titles when expanded', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
// Files are collapsed by default — expand all commit cards first
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
screen.getAllByLabelText('Expand files').forEach((btn) => fireEvent.click(btn))
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
expect(screen.getByText('new note')).toBeInTheDocument()
expect(screen.getByText('old')).toBeInTheDocument()
})
it('calls onOpenNote when clicking a non-deleted file', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
const onOpenNote = vi.fn()
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
// Expand first commit card
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
fireEvent.click(screen.getAllByLabelText('Expand files')[0])
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('my project'))
expect(onOpenNote).toHaveBeenCalledWith('project/my-project.md')
})
it('does not call onOpenNote when clicking a deleted file', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
const onOpenNote = vi.fn()
render(<PulseView vaultPath="/test/vault" onOpenNote={onOpenNote} />)
// Expand all commit cards to find the deleted file
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
screen.getAllByLabelText('Expand files').forEach((btn) => fireEvent.click(btn))
await waitFor(() => {
expect(screen.getByText('old')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('old'))
expect(onOpenNote).not.toHaveBeenCalled()
})
it('shows empty state when no commits', async () => {
mockInvokeFn.mockResolvedValue([])
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('No activity yet')).toBeInTheDocument()
})
})
it('shows error state and retry button', async () => {
mockInvokeFn.mockRejectedValue('Not a git repository')
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('Not a git repository')).toBeInTheDocument()
})
expect(screen.getByText('Retry')).toBeInTheDocument()
})
it('calls get_vault_pulse with skip=0 on initial load and passes correct page size', async () => {
mockInvokeFn.mockResolvedValue([])
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/test/vault', limit: 20, skip: 0 })
})
})
it('calls get_vault_pulse with correct arguments', async () => {
mockInvokeFn.mockResolvedValue([])
render(<PulseView vaultPath="/my/vault" />)
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('get_vault_pulse', { vaultPath: '/my/vault', limit: 20, skip: 0 })
})
})
it('toggles file list visibility when clicking expand/collapse button', async () => {
mockInvokeFn.mockResolvedValue(mockCommits)
render(<PulseView vaultPath="/test/vault" />)
// Files are collapsed by default
await waitFor(() => {
expect(screen.getAllByLabelText('Expand files').length).toBeGreaterThan(0)
})
expect(screen.queryByText('my project')).not.toBeInTheDocument()
// Click expand on first commit card
fireEvent.click(screen.getAllByLabelText('Expand files')[0])
// Files should now be visible
await waitFor(() => {
expect(screen.getByText('my project')).toBeInTheDocument()
})
// Click collapse to hide again
fireEvent.click(screen.getAllByLabelText('Collapse files')[0])
expect(screen.queryByText('my project')).not.toBeInTheDocument()
})
it('renders Pulse header', async () => {
mockInvokeFn.mockResolvedValue([])
render(<PulseView vaultPath="/test/vault" />)
await waitFor(() => {
expect(screen.getByText('Pulse')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,325 @@
import { useState, useEffect, useCallback, useRef, memo } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { PulseCommit, PulseFile } from '../types'
import { relativeDate } from '../utils/noteListHelpers'
import {
Plus, Minus, PencilSimple, GitCommit, ArrowSquareOut,
FileText, CaretDown, CaretRight, Pulse,
} from '@phosphor-icons/react'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
interface PulseViewProps {
vaultPath: string
onOpenNote?: (relativePath: string) => void
sidebarCollapsed?: boolean
onExpandSidebar?: () => void
}
function groupCommitsByDay(commits: PulseCommit[]): Map<string, PulseCommit[]> {
const groups = new Map<string, PulseCommit[]>()
for (const commit of commits) {
const date = new Date(commit.date * 1000)
const key = date.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
const existing = groups.get(key)
if (existing) {
existing.push(commit)
} else {
groups.set(key, [commit])
}
}
return groups
}
function isToday(dateKey: string): boolean {
const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === today
}
function isYesterday(dateKey: string): boolean {
const yesterday = new Date(Date.now() - 86400000)
.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
return dateKey === yesterday
}
function formatDayLabel(dateKey: string): string {
if (isToday(dateKey)) return 'Today'
if (isYesterday(dateKey)) return 'Yesterday'
return dateKey
}
const STATUS_ICON = {
added: Plus,
modified: PencilSimple,
deleted: Minus,
} as const
const STATUS_COLOR = {
added: 'var(--accent-green, #16a34a)',
modified: 'var(--accent-orange, #ea580c)',
deleted: 'var(--destructive, #dc2626)',
} as const
function SummaryBadges({ added, modified, deleted }: { added: number; modified: number; deleted: number }) {
return (
<div className="flex items-center" style={{ gap: 8 }}>
{added > 0 && <span className="text-[11px] font-medium" style={{ color: STATUS_COLOR.added }}>+{added}</span>}
{modified > 0 && <span className="text-[11px] font-medium" style={{ color: STATUS_COLOR.modified }}>~{modified}</span>}
{deleted > 0 && <span className="text-[11px] font-medium" style={{ color: STATUS_COLOR.deleted }}>-{deleted}</span>}
</div>
)
}
function FileItem({ file, onOpenNote }: { file: PulseFile; onOpenNote?: (path: string) => void }) {
const Icon = STATUS_ICON[file.status] ?? FileText
const color = STATUS_COLOR[file.status] ?? 'var(--muted-foreground)'
const isDeleted = file.status === 'deleted'
return (
<div
className={`flex items-center rounded transition-colors ${isDeleted ? '' : 'cursor-pointer hover:bg-accent'}`}
style={{ gap: 6, padding: '3px 8px' }}
onClick={isDeleted ? undefined : () => onOpenNote?.(file.path)}
title={file.path}
>
<Icon size={12} style={{ color, flexShrink: 0 }} weight="bold" />
<span
className={`truncate text-[12px] ${isDeleted ? 'text-muted-foreground line-through' : 'text-foreground'}`}
>
{file.title}
</span>
</div>
)
}
function CommitCard({ commit, onOpenNote }: { commit: PulseCommit; onOpenNote?: (path: string) => void }) {
const [expanded, setExpanded] = useState(false)
const Chevron = expanded ? CaretDown : CaretRight
return (
<div className="border-b border-border" style={{ padding: '10px 16px' }}>
<div className="flex items-start justify-between" style={{ gap: 8 }}>
<div className="min-w-0 flex-1">
<div className="flex items-center" style={{ gap: 6, marginBottom: 2 }}>
<GitCommit size={13} className="text-muted-foreground" style={{ flexShrink: 0 }} />
<span className="truncate text-[13px] font-medium text-foreground">{commit.message}</span>
</div>
<div className="flex items-center" style={{ gap: 8 }}>
<span className="text-[11px] text-muted-foreground">{relativeDate(commit.date)}</span>
{commit.githubUrl ? (
<a
className="flex items-center text-[11px] font-mono text-primary no-underline hover:underline"
style={{ gap: 3 }}
href={commit.githubUrl}
onClick={(e) => {
e.preventDefault()
if (isTauri()) {
import('@tauri-apps/plugin-opener').then((mod) => mod.openUrl(commit.githubUrl!))
} else {
window.open(commit.githubUrl!, '_blank')
}
}}
title="Open on GitHub"
>
{commit.shortHash}
<ArrowSquareOut size={10} />
</a>
) : (
<span className="text-[11px] font-mono text-muted-foreground">{commit.shortHash}</span>
)}
<SummaryBadges added={commit.added} modified={commit.modified} deleted={commit.deleted} />
</div>
</div>
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground hover:text-foreground"
style={{ width: 20, height: 20 }}
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? 'Collapse files' : 'Expand files'}
>
<Chevron size={12} />
</button>
</div>
{expanded && commit.files.length > 0 && (
<div style={{ marginTop: 6, marginLeft: 4 }}>
{commit.files.map((file) => (
<FileItem key={file.path} file={file} onOpenNote={onOpenNote} />
))}
</div>
)}
</div>
)
}
function DayGroup({ label, commits, onOpenNote }: {
label: string; commits: PulseCommit[]; onOpenNote?: (path: string) => void
}) {
const [collapsed, setCollapsed] = useState(false)
const Chevron = collapsed ? CaretRight : CaretDown
return (
<div>
<div
className="flex cursor-pointer select-none items-center border-b border-border bg-muted/50 transition-colors hover:bg-muted"
style={{ padding: '6px 16px', gap: 6 }}
onClick={() => setCollapsed((v) => !v)}
>
<Chevron size={12} className="text-muted-foreground" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span className="text-[11px] text-muted-foreground">
({commits.length} {commits.length === 1 ? 'commit' : 'commits'})
</span>
</div>
{!collapsed && commits.map((commit) => (
<CommitCard key={commit.hash} commit={commit} onOpenNote={onOpenNote} />
))}
</div>
)
}
function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<Pulse size={32} style={{ marginBottom: 8, opacity: 0.5 }} />
<p className="text-[13px]">No activity yet</p>
<p className="text-[12px]" style={{ marginTop: 4 }}>
Commit changes to see your vault's pulse
</p>
</div>
)
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="flex flex-1 flex-col items-center justify-center text-muted-foreground" style={{ padding: 32 }}>
<p className="text-[13px]">{message}</p>
<button
className="mt-2 cursor-pointer rounded border border-border bg-transparent px-3 py-1 text-[12px] text-foreground transition-colors hover:bg-accent"
onClick={onRetry}
>
Retry
</button>
</div>
)
}
const PAGE_SIZE = 20
export const PulseView = memo(function PulseView({ vaultPath, onOpenNote, sidebarCollapsed, onExpandSidebar }: PulseViewProps) {
const [commits, setCommits] = useState<PulseCommit[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasMore, setHasMore] = useState(true)
const [skip, setSkip] = useState(0)
const sentinelRef = useRef<HTMLDivElement | null>(null)
// Initial load
const loadInitial = useCallback(async () => {
setLoading(true)
setError(null)
setCommits([])
setSkip(0)
setHasMore(true)
try {
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip: 0 })
setCommits(result)
setHasMore(result.length >= PAGE_SIZE)
setSkip(result.length)
} catch (err) {
const msg = typeof err === 'string' ? err : 'Failed to load activity'
setError(msg)
} finally {
setLoading(false)
}
}, [vaultPath])
// Append next page
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return
setLoadingMore(true)
try {
const result = await tauriCall<PulseCommit[]>('get_vault_pulse', { vaultPath, limit: PAGE_SIZE, skip })
setCommits((prev) => [...prev, ...result])
setHasMore(result.length >= PAGE_SIZE)
setSkip((s) => s + result.length)
} catch {
// silently fail for pagination — user can scroll up/retry
} finally {
setLoadingMore(false)
}
}, [vaultPath, skip, loadingMore, hasMore])
useEffect(() => { loadInitial() }, [loadInitial])
// Intersection Observer for infinite scroll
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMore() },
{ threshold: 0.1 },
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [loadMore])
const dayGroups = groupCommitsByDay(commits)
return (
<div className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-background">
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-border" style={{ height: 52, padding: '0 16px' }}>
<div className="flex items-center" style={{ gap: 8 }}>
{sidebarCollapsed && onExpandSidebar && (
<button
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={{ width: 24, height: 24 }}
onClick={onExpandSidebar}
aria-label="Expand sidebar"
>
<CaretRight size={14} weight="bold" />
</button>
)}
<Pulse size={16} className="text-primary" />
<span className="text-[14px] font-semibold text-foreground">Pulse</span>
</div>
</div>
{/* Feed */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center" style={{ padding: 32 }}>
<span className="text-[13px] text-muted-foreground">Loading activity</span>
</div>
) : error ? (
<ErrorState message={error} onRetry={loadInitial} />
) : commits.length === 0 ? (
<EmptyState />
) : (
<>
{Array.from(dayGroups.entries()).map(([day, dayCommits]) => (
<DayGroup
key={day}
label={formatDayLabel(day)}
commits={dayCommits}
onOpenNote={onOpenNote}
/>
))}
{/* Sentinel for infinite scroll */}
<div ref={sentinelRef} style={{ height: 1 }} />
{loadingMore && (
<div className="flex items-center justify-center" style={{ padding: 12 }}>
<span className="text-[12px] text-muted-foreground">Loading</span>
</div>
)}
</>
)}
</div>
</div>
)
})

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut, Check, Plus } from '@phosphor-icons/react'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
import { ThemePropertyEditor } from './ThemePropertyEditor'
import type { Settings, ThemeFile } from '../types'
import type { ThemeManager } from '../hooks/useThemeManager'
@@ -356,6 +357,13 @@ function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) {
<Plus size={14} />
New Theme
</button>
{activeThemeId && (
<>
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<ThemePropertyEditor themeManager={themeManager} />
</>
)}
</>
)
}

View File

@@ -1,20 +1,8 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { Sidebar } from './Sidebar'
import type { VaultEntry, SidebarSelection } from '../types'
// Mock localStorage for section visibility tests
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
const mockEntries: VaultEntry[] = [
{
path: '/vault/project/build-app.md',
@@ -687,9 +675,96 @@ describe('Sidebar', () => {
})
})
describe('customize section visibility', () => {
beforeEach(() => {
localStorageMock.clear()
describe('type visibility via visible property', () => {
const makeTypeEntry = (title: string, visible: boolean | null): VaultEntry => ({
path: `/vault/type/${title.toLowerCase()}.md`,
filename: `${title.toLowerCase()}.md`,
title,
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
sort: null,
view: null,
visible,
outgoingLinks: [],
properties: {},
})
it('hides a section when its Type entry has visible: false', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', false),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('People')).not.toBeInTheDocument()
// Other sections should still be visible
expect(screen.getByText('Projects')).toBeInTheDocument()
})
it('shows a section when its Type entry has visible: true', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', true),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('People')).toBeInTheDocument()
})
it('shows a section when its Type entry has visible: null (default)', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', null),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('People')).toBeInTheDocument()
})
it('shows a section when there is no Type entry at all (default visible)', () => {
// mockEntries has Person instances but no Type entry for Person
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('People')).toBeInTheDocument()
})
it('hides multiple sections when their Type entries have visible: false', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Person', false),
makeTypeEntry('Event', false),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('People')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Topics')).toBeInTheDocument()
})
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
const entries: VaultEntry[] = [
...mockEntries,
makeTypeEntry('Project', false),
makeTypeEntry('Person', false),
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
})
it('renders a "Customize sections" button', () => {
@@ -706,66 +781,12 @@ describe('Sidebar', () => {
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
})
it('hides a section when its toggle is clicked off', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// People section header should be visible initially
expect(screen.getByText('People')).toBeInTheDocument()
// Open customize popover and toggle People off
it('calls onToggleTypeVisibility when toggling a section in the popover', () => {
const onToggleTypeVisibility = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onToggleTypeVisibility={onToggleTypeVisibility} />)
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle People'))
// People section header should be gone (use getAllByText to handle popover)
const peopleElements = screen.queryAllByText('People')
// Only the toggle label in the popover should remain, not the section header
expect(peopleElements.length).toBeLessThanOrEqual(1)
})
it('re-shows a section when its toggle is clicked on again', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// Hide People
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle People'))
// Show People again
fireEvent.click(screen.getByLabelText('Toggle People'))
// People section should be visible again — popover toggle + section header = 2 "People" texts
const peopleElements = screen.getAllByText('People')
expect(peopleElements.length).toBe(2)
})
it('persists hidden sections in localStorage', () => {
const { unmount } = render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle Events'))
unmount()
// Verify localStorage was updated
const stored = JSON.parse(localStorage.getItem('laputa-hidden-sections') || '[]')
expect(stored).toContain('Event')
})
it('restores hidden sections from localStorage on mount', () => {
localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Person', 'Event']))
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// People and Events section headers should be hidden
expect(screen.queryByText('People')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
// Other section headers should still be visible
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Topics')).toBeInTheDocument()
})
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Project', 'Person']))
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// Top nav items still present
expect(screen.getByText('All Notes')).toBeInTheDocument()
expect(screen.getByText('Favorites')).toBeInTheDocument()
expect(onToggleTypeVisibility).toHaveBeenCalledWith('Person')
})
it('closes popover when clicking outside', () => {
@@ -892,4 +913,42 @@ describe('Sidebar', () => {
expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument()
})
})
it('renders exactly one section for a hyphenated custom type like Monday Ideas', () => {
const entriesWithMondayIdeas: VaultEntry[] = [
...mockEntries,
{
path: '/vault/monday-ideas/standup-bingo.md',
filename: 'standup-bingo.md',
title: 'Standup Bingo',
isA: 'Monday Ideas',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: null,
fileSize: 310, snippet: '', wordCount: 120,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
{
path: '/vault/monday-ideas/theme-days.md',
filename: 'theme-days.md',
title: 'Theme Days',
isA: 'Monday Ideas',
aliases: [], belongsTo: [], relatedTo: [],
status: null, owner: null, cadence: null,
archived: false, trashed: false, trashedAt: null,
modifiedAt: 1700000000, createdAt: null,
fileSize: 280, snippet: '', wordCount: 95,
relationships: {}, icon: null, color: null, order: null,
sidebarLabel: null, template: null, sort: null, view: null,
outgoingLinks: [], properties: {},
},
]
render(<Sidebar entries={entriesWithMondayIdeas} selection={defaultSelection} onSelect={() => {}} />)
// "Monday Ideas" pluralized → "Monday Ideases" (the pluralizeType function)
const mondaySections = screen.getAllByText(/Monday Ideas/i)
expect(mondaySections).toHaveLength(1)
})
})

View File

@@ -4,7 +4,6 @@ import { resolveIcon } from '../utils/iconRegistry'
import { buildTypeEntryMap } from '../utils/typeColors'
import { pluralizeType } from '../hooks/useCommandRegistry'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import { useSectionVisibility } from '../hooks/useSectionVisibility'
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,
useSensor, useSensors, type DragEndEvent,
@@ -15,7 +14,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff,
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
} from '@phosphor-icons/react'
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
import {
@@ -35,9 +34,11 @@ interface SidebarProps {
onUpdateTypeTemplate?: (typeName: string, template: string) => void
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
onRenameSection?: (typeName: string, label: string) => void
onToggleTypeVisibility?: (typeName: string) => void
modifiedCount?: number
onCommitPush?: () => void
onCollapse?: () => void
isGitVault?: boolean
}
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
@@ -103,13 +104,13 @@ function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, Vault
})
}
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) {
function useSidebarSections(entries: VaultEntry[]) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {
const sections = buildDynamicSections(entries, typeEntryMap)
return sortSections(sections, typeEntryMap)
}, [entries, typeEntryMap])
const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible])
const visibleSections = useMemo(() => allSectionGroups.filter((g) => typeEntryMap[g.type]?.visible !== false), [allSectionGroups, typeEntryMap])
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])
return { typeEntryMap, allSectionGroups, visibleSections, sectionIds }
}
@@ -165,7 +166,7 @@ function SortableSection({ group, sectionProps }: {
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle' | 'isRenaming' | 'renameInitialValue'>
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
}) {
const { attributes, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed)
const isCollapsed = sectionProps.collapsed[group.type] ?? true
const isRenaming = sectionProps.renamingType === group.type
@@ -178,6 +179,7 @@ function SortableSection({ group, sectionProps }: {
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
onToggle={() => sectionProps.onToggle(group.type)}
dragHandleProps={listeners}
isRenaming={isRenaming}
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
onRenameSubmit={sectionProps.onRenameSubmit}
@@ -269,7 +271,8 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
modifiedCount = 0, onCommitPush, onCollapse,
onToggleTypeVisibility,
modifiedCount = 0, onCommitPush, onCollapse, isGitVault = false,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -283,8 +286,10 @@ export const Sidebar = memo(function Sidebar({
const popoverRef = useRef<HTMLDivElement>(null)
const customizeRef = useRef<HTMLDivElement>(null)
const { toggleSection: toggleVisibility, isSectionVisible } = useSectionVisibility()
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, isSectionVisible)
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap])
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
const { activeCount, archivedCount, trashedCount } = useEntryCounts(entries)
const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, [])
@@ -358,6 +363,7 @@ export const Sidebar = memo(function Sidebar({
{modifiedCount > 0 && (
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
)}
<NavItem icon={Pulse} label="Pulse" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'pulse' })} disabled={!isGitVault} disabledTooltip="Pulse is only available for git-enabled vaults" onClick={isGitVault ? () => onSelect({ kind: 'filter', filter: 'pulse' }) : undefined} />
</div>
{/* Sections header + visibility popover */}

View File

@@ -26,7 +26,7 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
// --- NavItem ---
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled }: {
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled, disabledTooltip }: {
icon: ComponentType<IconProps>
label: string
count?: number
@@ -36,10 +36,11 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
badgeStyle?: React.CSSProperties
onClick?: () => void
disabled?: boolean
disabledTooltip?: string
}) {
if (disabled) {
return (
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title="Coming soon">
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
<Icon size={16} />
<span className="flex-1 text-[13px] font-medium">{label}</span>
</div>
@@ -75,6 +76,7 @@ export interface SectionContentProps {
onCreateNewType?: () => void
onContextMenu: (e: React.MouseEvent, type: string) => void
onToggle: () => void
dragHandleProps?: Record<string, unknown>
isRenaming?: boolean
renameInitialValue?: string
onRenameSubmit?: (value: string) => void
@@ -93,7 +95,7 @@ function resolveCreateHandler(type: string, onCreateType?: (type: string) => voi
export function SectionContent({
group, items, isCollapsed, selection, onSelect, onSelectNote,
onCreateType, onCreateNewType, onContextMenu, onToggle,
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
@@ -113,6 +115,7 @@ export function SectionContent({
onContextMenu={(e) => onContextMenu(e, type)}
onToggle={onToggle}
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
dragHandleProps={dragHandleProps}
isRenaming={isRenaming}
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
@@ -182,11 +185,12 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
)
}
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
label: string; type: string; Icon: ComponentType<IconProps>
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
dragHandleProps?: Record<string, unknown>
isRenaming?: boolean; renameInitialValue?: string
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
}) {
@@ -194,6 +198,7 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
<div
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
{...dragHandleProps}
onClick={() => {
if (isRenaming) return
if (isCollapsed) { onToggle(); onSelect() }

View File

@@ -265,17 +265,46 @@ describe('StatusBar', () => {
expect(screen.getByText('Index ready')).toBeInTheDocument()
})
it('shows error state in indexing badge', () => {
it('shows error state in indexing badge with retry label', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd not available' }}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
/>
)
expect(screen.getByText('Index error')).toBeInTheDocument()
expect(screen.getByText('Index failed — retry')).toBeInTheDocument()
})
it('hides indexing badge when phase is unavailable', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'unavailable', current: 0, total: 0, done: true, error: 'qmd not available' }}
/>
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('calls onRetryIndexing when clicking error badge', () => {
const onRetryIndexing = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd update failed' }}
onRetryIndexing={onRetryIndexing}
/>
)
fireEvent.click(screen.getByTestId('status-indexing'))
expect(onRetryIndexing).toHaveBeenCalledOnce()
})
it('hides indexing badge when phase is idle', () => {
@@ -310,4 +339,59 @@ describe('StatusBar', () => {
)
expect(screen.getByText('Installing search…')).toBeInTheDocument()
})
it('shows MCP warning badge when status is not_installed', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
expect(screen.getByTitle('MCP server not installed — click to install')).toBeInTheDocument()
})
it('shows MCP warning badge when status is no_claude_cli', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" />
)
expect(screen.getByTestId('status-mcp')).toBeInTheDocument()
expect(screen.getByTitle('Claude CLI not found — install it first')).toBeInTheDocument()
})
it('hides MCP badge when status is installed', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="installed" />
)
expect(screen.queryByTestId('status-mcp')).not.toBeInTheDocument()
})
it('hides MCP badge when status is checking', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="checking" />
)
expect(screen.queryByTestId('status-mcp')).not.toBeInTheDocument()
})
it('hides MCP badge when no mcpStatus prop provided', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
)
expect(screen.queryByTestId('status-mcp')).not.toBeInTheDocument()
})
it('calls onInstallMcp when clicking MCP badge with not_installed status', () => {
const onInstallMcp = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="not_installed" onInstallMcp={onInstallMcp} />
)
fireEvent.click(screen.getByTestId('status-mcp'))
expect(onInstallMcp).toHaveBeenCalledOnce()
})
it('does not call onInstallMcp when clicking MCP badge with no_claude_cli status', () => {
const onInstallMcp = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} mcpStatus="no_claude_cli" onInstallMcp={onInstallMcp} />
)
fireEvent.click(screen.getByTestId('status-mcp'))
expect(onInstallMcp).not.toHaveBeenCalled()
})
})

View File

@@ -1,7 +1,8 @@
import { useState, useRef, useEffect } from 'react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X } from 'lucide-react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X, Cpu } from 'lucide-react'
import type { LastCommitInfo, SyncStatus } from '../types'
import type { IndexingProgress } from '../hooks/useIndexing'
import type { McpStatus } from '../hooks/useMcpStatus'
import { openExternalUrl } from '../utils/url'
export interface VaultOption {
@@ -32,7 +33,10 @@ interface StatusBarProps {
buildNumber?: string
onCheckForUpdates?: () => void
indexingProgress?: IndexingProgress
onRetryIndexing?: () => void
onRemoveVault?: (path: string) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
}
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
@@ -237,23 +241,31 @@ const INDEXING_LABELS: Record<string, string> = {
scanning: 'Indexing…',
embedding: 'Embedding…',
complete: 'Index ready',
error: 'Index error',
error: 'Index failed — retry',
unavailable: 'Search unavailable',
}
function IndexingBadge({ progress }: { progress: IndexingProgress }) {
if (progress.phase === 'idle') return null
function IndexingBadge({ progress, onRetry }: { progress: IndexingProgress; onRetry?: () => void }) {
if (progress.phase === 'idle' || progress.phase === 'unavailable') return null
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const isError = progress.phase === 'error'
const showCount = progress.total > 0 && isActive
const displayText = showCount
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
: label
const color = progress.phase === 'error' ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
const color = isError ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
return (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color }} data-testid="status-indexing">
<span
role={isError && onRetry ? 'button' : undefined}
onClick={isError && onRetry ? onRetry : undefined}
style={{ ...ICON_STYLE, color, cursor: isError && onRetry ? 'pointer' : 'default' }}
title={isError ? 'Click to retry indexing' : undefined}
data-testid="status-indexing"
>
{isActive
? <Loader2 size={13} className="animate-spin" />
: <Search size={13} />
@@ -282,7 +294,42 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRemoveVault }: StatusBarProps) {
const MCP_TOOLTIPS: Record<string, string> = {
not_installed: 'MCP server not installed — click to install',
no_claude_cli: 'Claude CLI not found — install it first',
}
function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () => void }) {
if (status === 'installed' || status === 'checking') return null
const tooltip = MCP_TOOLTIPS[status] ?? 'MCP status unknown'
const clickable = status === 'not_installed' && !!onInstall
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={clickable ? 'button' : undefined}
onClick={clickable ? onInstall : undefined}
style={{
...ICON_STYLE,
color: 'var(--accent-orange)',
cursor: clickable ? 'pointer' : 'default',
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
}}
title={tooltip}
data-testid="status-mcp"
onMouseEnter={clickable ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={clickable ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
>
<Cpu size={13} />MCP
<AlertTriangle size={10} style={{ marginLeft: 2 }} />
</span>
</>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRetryIndexing, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -308,7 +355,8 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} />}
{indexingProgress && <IndexingBadge progress={indexingProgress} onRetry={onRetryIndexing} />}
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { ThemePropertyEditor } from './ThemePropertyEditor'
import type { ThemeManager } from '../hooks/useThemeManager'
function makeThemeManager(overrides: Partial<ThemeManager> = {}): ThemeManager {
return {
themes: [],
activeThemeId: '/vault/_themes/My Theme.md',
activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} },
activeThemeContent: '---\nIs A: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
isDark: false,
switchTheme: vi.fn(),
createTheme: vi.fn().mockResolvedValue(''),
reloadThemes: vi.fn(),
updateThemeProperty: vi.fn(),
...overrides,
}
}
describe('ThemePropertyEditor', () => {
it('shows message when no theme is active', () => {
const tm = makeThemeManager({ activeThemeId: null })
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByText(/Select a theme/)).toBeInTheDocument()
})
it('renders the editor when a theme is active', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByTestId('theme-property-editor')).toBeInTheDocument()
})
it('shows section headers for all theme.json sections', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByText('Typography')).toBeInTheDocument()
expect(screen.getByText('Headings')).toBeInTheDocument()
expect(screen.getByText('Lists')).toBeInTheDocument()
expect(screen.getByText('Code Blocks')).toBeInTheDocument()
expect(screen.getByText('Blockquote')).toBeInTheDocument()
expect(screen.getByText('Table')).toBeInTheDocument()
expect(screen.getByText('Horizontal Rule')).toBeInTheDocument()
expect(screen.getByText('Colors')).toBeInTheDocument()
})
it('shows active theme name', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
expect(screen.getByText('My Theme')).toBeInTheDocument()
})
it('expands Typography section by default', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// Typography section should be expanded, showing its properties
expect(screen.getByTestId('theme-input-editor-font-size')).toBeInTheDocument()
})
it('shows current theme value for overridden properties', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement
expect(fontSizeInput.value).toBe('18')
})
it('shows default value for non-overridden properties', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// editor-max-width is not in the theme content, so it should show the default (720)
const maxWidthInput = screen.getByTestId('theme-input-editor-max-width') as HTMLInputElement
expect(maxWidthInput.value).toBe('720')
})
it('calls updateThemeProperty on number input change', async () => {
vi.useFakeTimers()
const updateFn = vi.fn()
const tm = makeThemeManager({ updateThemeProperty: updateFn })
render(<ThemePropertyEditor themeManager={tm} />)
const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement
fireEvent.change(fontSizeInput, { target: { value: '16' } })
// Debounce fires after 300ms
act(() => { vi.advanceTimersByTime(300) })
expect(updateFn).toHaveBeenCalledWith('editor-font-size', '16px')
vi.useRealTimers()
})
it('expands collapsed sections on click', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// Lists section should be collapsed by default
expect(screen.queryByTestId('theme-input-lists-bullet-size')).not.toBeInTheDocument()
// Click to expand
fireEvent.click(screen.getByTestId('theme-section-lists-toggle'))
expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
})
it('toggles section via keyboard Enter', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
const toggle = screen.getByTestId('theme-section-lists-toggle')
fireEvent.keyDown(toggle, { key: 'Enter' })
expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
})
it('toggles section via keyboard Space', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
const toggle = screen.getByTestId('theme-section-lists-toggle')
fireEvent.keyDown(toggle, { key: ' ' })
expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
})
it('shows heading subsections after expanding Headings', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
fireEvent.click(screen.getByTestId('theme-section-headings-toggle'))
expect(screen.getByText('Heading 1')).toBeInTheDocument()
expect(screen.getByText('Heading 2')).toBeInTheDocument()
expect(screen.getByText('Heading 3')).toBeInTheDocument()
expect(screen.getByText('Heading 4')).toBeInTheDocument()
})
it('shows unit label for numeric properties', () => {
const tm = makeThemeManager()
render(<ThemePropertyEditor themeManager={tm} />)
// "px" should appear near Font Size input
const container = screen.getByTestId('theme-input-editor-font-size').parentElement!
expect(container.textContent).toContain('px')
})
})

View File

@@ -0,0 +1,321 @@
import { useState, useCallback, useMemo, useRef } from 'react'
import { CaretRight } from '@phosphor-icons/react'
import { ColorSwatch } from './ColorInput'
import { getThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from '../utils/themeSchema'
import type { ThemeProperty, ThemeSection, ThemeSubsection } from '../utils/themeSchema'
import type { ThemeManager } from '../hooks/useThemeManager'
import { parseFrontmatter } from '../utils/frontmatter'
import { isValidCssColor } from '../utils/colorUtils'
/** Extract current theme property values from frontmatter content. */
function useThemeValues(content: string | undefined): Record<string, string> {
return useMemo(() => {
if (!content) return {}
const fm = parseFrontmatter(content)
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(fm)) {
if (typeof value === 'string') result[key] = value
else if (typeof value === 'number') result[key] = String(value)
else if (typeof value === 'boolean') result[key] = String(value)
}
return result
}, [content])
}
// --- Individual input components ---
function NumberInput({ property, value, onChange }: {
property: ThemeProperty
value: string | number
onChange: (val: string) => void
}) {
const numericValue = typeof value === 'number' ? value : parseFloat(String(value)) || 0
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
if (raw === '' || raw === '-') return
const num = parseFloat(raw)
if (isNaN(num)) return
if (property.min !== undefined && num < property.min) return
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
onChange(formatValueForFrontmatter(num, property))
}, 300)
}, [onChange, property])
return (
<div className="flex items-center gap-1">
<input
type="number"
defaultValue={numericValue}
onChange={handleChange}
min={property.min}
step={property.unit ? 1 : 0.1}
className="w-20 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
/>
{property.unit && (
<span className="text-[11px] text-muted-foreground">{property.unit}</span>
)}
</div>
)
}
function ColorInput({ property, value, onChange }: {
property: ThemeProperty
value: string
onChange: (val: string) => void
}) {
const [localValue, setLocalValue] = useState(value)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const showSwatch = isValidCssColor(localValue)
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.value
setLocalValue(newVal)
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => onChange(newVal), 300)
}, [onChange])
const handlePickerChange = useCallback((hex: string) => {
setLocalValue(hex)
onChange(hex)
}, [onChange])
return (
<div className="flex items-center gap-1.5">
{showSwatch && <ColorSwatch color={localValue} onChange={handlePickerChange} />}
<input
type="text"
value={localValue}
onChange={handleTextChange}
className="w-28 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
/>
</div>
)
}
function SelectInput({ property, value, onChange }: {
property: ThemeProperty
value: string
onChange: (val: string) => void
}) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className="w-28 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
>
{property.options?.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
)
}
function TextInput({ property, value, onChange }: {
property: ThemeProperty
value: string
onChange: (val: string) => void
}) {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = e.target.value
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => onChange(newVal), 500)
}, [onChange])
return (
<input
type="text"
defaultValue={value}
onChange={handleChange}
className="w-40 rounded border border-border bg-transparent px-2 py-1 text-xs text-foreground outline-none focus:border-primary"
data-testid={`theme-input-${property.cssVar}`}
/>
)
}
// --- Property row ---
function PropertyRow({ property, currentValue, onUpdate }: {
property: ThemeProperty
currentValue: string | undefined
onUpdate: (cssVar: string, value: string) => void
}) {
const displayValue = currentValue !== undefined
? parseValueFromFrontmatter(currentValue, property)
: property.defaultValue
const isPlaceholder = currentValue === undefined
const handleChange = useCallback((val: string) => {
onUpdate(property.cssVar, val)
}, [property.cssVar, onUpdate])
return (
<div
className="flex items-center justify-between gap-2 py-1"
style={{ minHeight: 28 }}
>
<label
className="text-xs shrink-0"
style={{ color: isPlaceholder ? 'var(--muted-foreground)' : 'var(--foreground)', minWidth: 100 }}
>
{property.label}
</label>
<div className="flex-shrink-0">
{property.inputType === 'number' && (
<NumberInput property={property} value={displayValue} onChange={handleChange} />
)}
{property.inputType === 'color' && (
<ColorInput property={property} value={String(displayValue)} onChange={handleChange} />
)}
{property.inputType === 'select' && (
<SelectInput property={property} value={String(displayValue)} onChange={handleChange} />
)}
{property.inputType === 'text' && (
<TextInput property={property} value={String(displayValue)} onChange={handleChange} />
)}
</div>
</div>
)
}
// --- Collapsible section ---
function CollapsibleSection({ label, defaultOpen, children, testId }: {
label: string
defaultOpen?: boolean
children: React.ReactNode
testId?: string
}) {
const [open, setOpen] = useState(defaultOpen ?? false)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen(prev => !prev)
}
}, [])
return (
<div data-testid={testId}>
<button
type="button"
className="flex w-full items-center gap-1 border-none bg-transparent p-0 cursor-pointer"
style={{ fontSize: 12, fontWeight: 600, color: 'var(--foreground)', padding: '4px 0' }}
onClick={() => setOpen(prev => !prev)}
onKeyDown={handleKeyDown}
aria-expanded={open}
data-testid={testId ? `${testId}-toggle` : undefined}
>
<CaretRight
size={12}
weight="bold"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s' }}
/>
{label}
</button>
{open && (
<div style={{ paddingLeft: 16 }}>
{children}
</div>
)}
</div>
)
}
// --- Section renderers ---
function SubsectionBlock({ subsection, currentValues, onUpdate }: {
subsection: ThemeSubsection
currentValues: Record<string, string>
onUpdate: (cssVar: string, value: string) => void
}) {
return (
<CollapsibleSection label={subsection.label} testId={`theme-sub-${subsection.id}`}>
{subsection.properties.map(prop => (
<PropertyRow
key={prop.cssVar}
property={prop}
currentValue={currentValues[prop.cssVar]}
onUpdate={onUpdate}
/>
))}
</CollapsibleSection>
)
}
function SectionBlock({ section, currentValues, onUpdate }: {
section: ThemeSection
currentValues: Record<string, string>
onUpdate: (cssVar: string, value: string) => void
}) {
return (
<CollapsibleSection
label={section.label}
defaultOpen={section.id === 'editor'}
testId={`theme-section-${section.id}`}
>
{section.properties.map(prop => (
<PropertyRow
key={prop.cssVar}
property={prop}
currentValue={currentValues[prop.cssVar]}
onUpdate={onUpdate}
/>
))}
{section.subsections.map(sub => (
<SubsectionBlock
key={sub.id}
subsection={sub}
currentValues={currentValues}
onUpdate={onUpdate}
/>
))}
</CollapsibleSection>
)
}
// --- Main component ---
export function ThemePropertyEditor({ themeManager }: { themeManager: ThemeManager }) {
const schema = useMemo(() => getThemeSchema(), [])
const currentValues = useThemeValues(themeManager.activeThemeContent)
const handleUpdate = useCallback((cssVar: string, value: string) => {
themeManager.updateThemeProperty(cssVar, value)
}, [themeManager])
if (!themeManager.activeThemeId) {
return (
<div className="text-xs text-muted-foreground" style={{ padding: '8px 0' }}>
Select a theme to customize its properties.
</div>
)
}
return (
<div
className="flex flex-col gap-1"
data-testid="theme-property-editor"
style={{ fontSize: 12 }}
>
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', marginBottom: 4 }}>
Editing: <strong>{themeManager.activeTheme?.name ?? 'Theme'}</strong>
</div>
{schema.map(section => (
<SectionBlock
key={section.id}
section={section}
currentValues={currentValues}
onUpdate={handleUpdate}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,283 @@
import { useState } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { WikilinkChatInput } from './WikilinkChatInput'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: 1700000000,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
...overrides,
})
const entries: VaultEntry[] = [
makeEntry({ path: '/vault/alpha.md', title: 'Alpha', filename: 'alpha.md', isA: 'Project' }),
makeEntry({ path: '/vault/beta.md', title: 'Beta', filename: 'beta.md', isA: 'Person' }),
makeEntry({ path: '/vault/gamma.md', title: 'Gamma', filename: 'gamma.md' }),
makeEntry({ path: '/vault/trashed.md', title: 'Trashed', filename: 'trashed.md', trashed: true }),
makeEntry({ path: '/vault/archived.md', title: 'Archived', filename: 'archived.md', archived: true }),
]
/** Wrapper that manages controlled value state so onChange/selectSuggestion work correctly. */
function Controlled({ onSend, ...props }: {
entries: VaultEntry[]
onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void
disabled?: boolean
placeholder?: string
}) {
const [value, setValue] = useState('')
return (
<WikilinkChatInput
entries={props.entries}
value={value}
onChange={setValue}
onSend={onSend ?? vi.fn()}
disabled={props.disabled}
placeholder={props.placeholder}
/>
)
}
/** Helper: type in the input and wait for debounce to flush. */
async function typeAndWait(text: string) {
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: text, selectionStart: text.length },
})
await act(() => { vi.advanceTimersByTime(150) })
}
describe('WikilinkChatInput', () => {
it('renders input with placeholder', () => {
render(<Controlled entries={entries} placeholder="Ask something..." />)
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask something...')
})
it('calls onChange when typing', () => {
const onChange = vi.fn()
render(
<WikilinkChatInput entries={entries} value="" onChange={onChange} onSend={vi.fn()} />,
)
fireEvent.change(screen.getByTestId('agent-input'), { target: { value: 'hello' } })
expect(onChange).toHaveBeenCalledWith('hello')
})
it('shows wikilink menu when [[ is typed', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[a')
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
vi.useRealTimers()
})
it('filters suggestions matching query', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
const menu = screen.getByTestId('wikilink-menu')
expect(menu.textContent).toContain('Alpha')
expect(menu.textContent).not.toContain('Beta')
vi.useRealTimers()
})
it('excludes trashed and archived entries from suggestions', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[t')
const menu = screen.queryByTestId('wikilink-menu')
if (menu) {
expect(menu.textContent).not.toContain('Trashed')
expect(menu.textContent).not.toContain('Archived')
}
vi.useRealTimers()
})
it('selects suggestion on click and creates pill', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[a')
const menu = screen.getByTestId('wikilink-menu')
const items = menu.querySelectorAll('[class*="cursor-pointer"]')
expect(items.length).toBeGreaterThan(0)
fireEvent.click(items[0])
const pills = screen.queryAllByTestId('reference-pill')
expect(pills.length).toBe(1)
vi.useRealTimers()
})
it('does not duplicate pills for same note', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
// First selection
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
// Second selection of same note
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1) // No duplicate
vi.useRealTimers()
})
it('removes pill when x button is clicked', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
const pill = screen.getByTestId('reference-pill')
fireEvent.click(pill.querySelector('button')!)
expect(screen.queryAllByTestId('reference-pill').length).toBe(0)
vi.useRealTimers()
})
it('navigates suggestions with keyboard', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[a')
const input = screen.getByTestId('agent-input')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'ArrowUp' })
// Escape closes menu
fireEvent.keyDown(input, { key: 'Escape' })
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
vi.useRealTimers()
})
it('selects suggestion with Enter key', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
vi.useRealTimers()
})
it('calls onSend with text and references on Enter without menu', async () => {
vi.useFakeTimers()
const onSend = vi.fn()
render(<Controlled entries={entries} onSend={onSend} />)
// Type non-wikilink text
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: 'hello', selectionStart: 5 },
})
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).toHaveBeenCalledWith('hello', [])
vi.useRealTimers()
})
it('does not send on Enter+Shift', () => {
const onSend = vi.fn()
render(
<WikilinkChatInput entries={entries} value="hello" onChange={vi.fn()} onSend={onSend} />,
)
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter', shiftKey: true })
expect(onSend).not.toHaveBeenCalled()
})
it('does not send on Enter when input is empty', () => {
const onSend = vi.fn()
render(
<WikilinkChatInput entries={entries} value="" onChange={vi.fn()} onSend={onSend} />,
)
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).not.toHaveBeenCalled()
})
it('disables input when disabled prop is true', () => {
render(<Controlled entries={entries} disabled />)
expect((screen.getByTestId('agent-input') as HTMLInputElement).disabled).toBe(true)
})
it('closes menu when ]] is typed', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
expect(screen.getByTestId('wikilink-menu')).toBeTruthy()
// Type ]] to close
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: '[[alp]]', selectionStart: 7 },
})
expect(screen.queryByTestId('wikilink-menu')).toBeNull()
vi.useRealTimers()
})
it('shows type badge for non-Note types in suggestions', async () => {
vi.useFakeTimers()
render(<Controlled entries={entries} />)
await typeAndWait('[[alp')
const menu = screen.getByTestId('wikilink-menu')
expect(menu.textContent).toContain('Project')
vi.useRealTimers()
})
it('matches by alias', async () => {
vi.useFakeTimers()
const entriesWithAlias = [
...entries,
makeEntry({ path: '/vault/delta.md', title: 'Delta', filename: 'delta.md', aliases: ['DLT'] }),
]
render(<Controlled entries={entriesWithAlias} />)
await typeAndWait('[[DLT')
const menu = screen.getByTestId('wikilink-menu')
expect(menu.textContent).toContain('Delta')
vi.useRealTimers()
})
it('sends references with onSend after selecting pills', async () => {
vi.useFakeTimers()
const onSend = vi.fn()
render(<Controlled entries={entries} onSend={onSend} />)
// Select a pill first
await typeAndWait('[[alp')
fireEvent.click(screen.getByTestId('wikilink-menu').querySelectorAll('[class*="cursor-pointer"]')[0])
expect(screen.queryAllByTestId('reference-pill').length).toBe(1)
// Type a message and send
fireEvent.change(screen.getByTestId('agent-input'), {
target: { value: 'tell me about it', selectionStart: 16 },
})
fireEvent.keyDown(screen.getByTestId('agent-input'), { key: 'Enter' })
expect(onSend).toHaveBeenCalledOnce()
const [text, refs] = onSend.mock.calls[0]
expect(text).toBe('tell me about it')
expect(refs).toHaveLength(1)
expect(refs[0].title).toBe('Alpha')
expect(refs[0].path).toBe('/vault/alpha.md')
vi.useRealTimers()
})
})

View File

@@ -0,0 +1,264 @@
/**
* Chat input with [[wikilink]] autocomplete.
*
* When the user types `[[`, a dropdown appears with vault note suggestions.
* Selecting a note inserts a colored pill in the input and records a reference.
*/
import { useState, useRef, useMemo, useEffect } from 'react'
import type { VaultEntry } from '../types'
import type { NoteReference } from '../utils/ai-context'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
const MAX_SUGGESTIONS = 20
const MIN_QUERY_LENGTH = 1
const DEBOUNCE_MS = 100
interface WikilinkChatInputProps {
entries: VaultEntry[]
value: string
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
disabled?: boolean
placeholder?: string
inputRef?: React.RefObject<HTMLInputElement | null>
}
interface Pill {
title: string
path: string
type: string | null
color?: string
lightColor?: string
}
interface SuggestionEntry {
title: string
path: string
isA: string | null
color?: string
lightColor?: string
}
function matchEntries(
entries: VaultEntry[],
query: string,
typeEntryMap: Record<string, VaultEntry>,
): SuggestionEntry[] {
if (query.length < MIN_QUERY_LENGTH) return []
const lower = query.toLowerCase()
const matches = entries.filter(e =>
!e.trashed && !e.archived && (
e.title.toLowerCase().includes(lower) ||
e.aliases.some(a => a.toLowerCase().includes(lower))
),
)
return matches.slice(0, MAX_SUGGESTIONS).map(e => {
const te = typeEntryMap[e.isA ?? '']
return {
title: e.title,
path: e.path,
isA: e.isA,
color: e.isA ? getTypeColor(e.isA, te?.color) : undefined,
lightColor: e.isA ? getTypeLightColor(e.isA, te?.color) : undefined,
}
})
}
export function WikilinkChatInput({
entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef,
}: WikilinkChatInputProps) {
const [pills, setPills] = useState<Pill[]>([])
const [showMenu, setShowMenu] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const [debouncedQuery, setDebouncedQuery] = useState('')
const internalRef = useRef<HTMLInputElement>(null)
const inputRefToUse = externalRef ?? internalRef
const menuRef = useRef<HTMLDivElement>(null)
const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const suggestions = useMemo(
() => showMenu ? matchEntries(entries, debouncedQuery, typeEntryMap) : [],
[entries, debouncedQuery, typeEntryMap, showMenu],
)
// Clamp selection to valid range
const clampedIndex = suggestions.length > 0
? Math.min(selectedIndex, suggestions.length - 1)
: 0
useEffect(() => {
if (!menuRef.current || clampedIndex < 0) return
const el = menuRef.current.children[clampedIndex] as HTMLElement | undefined
el?.scrollIntoView?.({ block: 'nearest' })
}, [clampedIndex])
function selectSuggestion(suggestion: SuggestionEntry) {
const cursor = inputRefToUse.current?.selectionStart ?? value.length
const textBefore = value.slice(0, cursor)
const bracketIdx = textBefore.lastIndexOf('[[')
if (bracketIdx < 0) return
const textAfter = value.slice(cursor)
const newValue = textBefore.slice(0, bracketIdx) + textAfter
onChange(newValue)
setPills(prev => {
if (prev.some(p => p.path === suggestion.path)) return prev
return [...prev, {
title: suggestion.title,
path: suggestion.path,
type: suggestion.isA,
color: suggestion.color,
lightColor: suggestion.lightColor,
}]
})
setShowMenu(false)
setTimeout(() => inputRefToUse.current?.focus(), 0)
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const newValue = e.target.value
onChange(newValue)
const cursor = e.target.selectionStart ?? newValue.length
const textBefore = newValue.slice(0, cursor)
const bracketIdx = textBefore.lastIndexOf('[[')
if (bracketIdx >= 0 && !textBefore.slice(bracketIdx).includes(']]')) {
const query = textBefore.slice(bracketIdx + 2)
setShowMenu(true)
setSelectedIndex(0)
clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS)
} else {
setShowMenu(false)
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (showMenu && suggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => (i + 1) % suggestions.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1))
return
}
if (e.key === 'Enter') {
e.preventDefault()
selectSuggestion(suggestions[clampedIndex])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setShowMenu(false)
return
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!value.trim() && pills.length === 0) return
const references: NoteReference[] = pills.map(p => ({
title: p.title,
path: p.path,
type: p.type,
}))
onSend(value, references)
setPills([])
}
}
return (
<div style={{ position: 'relative' }}>
{pills.length > 0 && (
<div className="flex flex-wrap gap-1" style={{ marginBottom: 4 }}>
{pills.map(pill => (
<span
key={pill.path}
className="inline-flex items-center gap-1 text-xs"
style={{
background: pill.lightColor ?? 'var(--muted)',
color: pill.color ?? 'var(--foreground)',
borderRadius: 9999,
padding: '1px 8px 1px 6px',
fontWeight: 500,
}}
data-testid="reference-pill"
>
{pill.title}
<button
className="border-none bg-transparent p-0 cursor-pointer"
style={{ color: 'inherit', opacity: 0.6, fontSize: 10, lineHeight: 1 }}
onClick={() => setPills(prev => prev.filter(p => p.path !== pill.path))}
tabIndex={-1}
>
×
</button>
</span>
))}
</div>
)}
<input
ref={inputRefToUse}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
className="flex-1 border border-border bg-transparent text-foreground"
style={{
fontSize: 13, borderRadius: 8, padding: '8px 10px',
outline: 'none', fontFamily: 'inherit', width: '100%', boxSizing: 'border-box',
}}
placeholder={placeholder}
disabled={disabled}
data-testid="agent-input"
/>
{showMenu && suggestions.length > 0 && (
<div
ref={menuRef}
className="wikilink-menu"
style={{
position: 'absolute', bottom: '100%', left: 0, right: 0,
marginBottom: 4, maxHeight: 260, overflowY: 'auto',
}}
data-testid="wikilink-menu"
>
{suggestions.map((s, i) => (
<div
key={s.path}
className="flex items-center justify-between gap-2 cursor-pointer transition-colors"
style={{
padding: '6px 10px',
fontSize: 13,
background: i === clampedIndex ? 'var(--accent)' : undefined,
}}
onMouseDown={e => e.preventDefault()}
onClick={() => selectSuggestion(s)}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="truncate">{s.title}</span>
{s.isA && s.isA !== 'Note' && (
<span
className="shrink-0 text-xs"
style={{
color: s.color,
backgroundColor: s.lightColor,
borderRadius: 9999,
padding: '1px 8px',
}}
>
{s.isA}
</span>
)}
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { useState } from 'react'
import type { VaultEntry } from '../../types'
import { CaretRight, Trash } from '@phosphor-icons/react'
import { getTypeColor } from '../../utils/typeColors'
import { entryStatusTitle } from './shared'
import { StatusSuffix } from './LinkButton'
export interface BacklinkItem {
entry: VaultEntry
context: string | null
}
function BacklinkEntry({ entry, context, typeEntryMap, onNavigate }: {
entry: VaultEntry
context: string | null
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const te = typeEntryMap[entry.isA ?? '']
const isDimmed = entry.archived || entry.trashed
return (
<button
className="flex w-full cursor-pointer flex-col items-start gap-0.5 border-none bg-transparent p-0 text-left hover:opacity-80"
onClick={() => onNavigate(entry.title)}
title={entryStatusTitle(entry)}
>
<span
className="flex items-center gap-1 text-xs font-medium"
style={{ color: isDimmed ? 'var(--muted-foreground)' : getTypeColor(entry.isA, te?.color) }}
>
{entry.trashed && <Trash size={12} className="shrink-0" />}
{entry.title}
<StatusSuffix isArchived={entry.archived} isTrashed={entry.trashed} />
</span>
{context && (
<span className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{context}
</span>
)}
</button>
)
}
export function BacklinksPanel({ backlinks, typeEntryMap, onNavigate }: {
backlinks: BacklinkItem[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const [expanded, setExpanded] = useState(false)
if (backlinks.length === 0) return null
return (
<div>
<button
className="font-mono-overline mb-2 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left text-muted-foreground hover:text-foreground"
onClick={() => setExpanded((v) => !v)}
data-testid="backlinks-toggle"
>
<CaretRight
size={12}
className="shrink-0 transition-transform"
style={{ transform: expanded ? 'rotate(90deg)' : undefined }}
/>
Backlinks ({backlinks.length})
</button>
{expanded && (
<div className="flex flex-col gap-1.5" data-testid="backlinks-list">
{backlinks.map(({ entry, context }) => (
<BacklinkEntry
key={entry.path}
entry={entry}
context={context}
typeEntryMap={typeEntryMap}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import type { GitCommit } from '../../types'
function formatRelativeDate(timestamp: number): string {
const now = Math.floor(Date.now() / 1000)
const days = Math.floor((now - timestamp) / 86400)
if (days < 1) return 'today'
if (days === 1) return 'yesterday'
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
return months === 1 ? '1mo ago' : `${months}mo ago`
}
export function GitHistoryPanel({ commits, onViewCommitDiff }: { commits: GitCommit[]; onViewCommitDiff?: (commitHash: string) => void }) {
return (
<div>
<h4 className="font-mono-overline mb-2 text-muted-foreground">History</h4>
{commits.length === 0
? <p className="m-0 text-[13px] text-muted-foreground">No revision history</p>
: (
<div className="flex flex-col gap-2.5">
{commits.map((c) => (
<div key={c.hash} style={{ borderLeft: '2px solid var(--border)', paddingLeft: 10 }}>
<div className="mb-0.5 flex items-center justify-between">
<button className="border-none bg-transparent p-0 font-mono text-primary cursor-pointer hover:underline" style={{ fontSize: 11 }} onClick={() => onViewCommitDiff?.(c.hash)} title={`View diff for ${c.shortHash}`}>{c.shortHash}</button>
<span className="text-muted-foreground" style={{ fontSize: 10 }}>{formatRelativeDate(c.date)}</span>
</div>
<div className="truncate text-xs text-secondary-foreground">{c.message}</div>
{c.author && <div className="truncate text-muted-foreground" style={{ fontSize: 10, marginTop: 1 }}>{c.author}</div>}
</div>
))}
</div>
)
}
</div>
)
}

View File

@@ -0,0 +1,55 @@
import type { ComponentType, SVGAttributes } from 'react'
import { Trash, X } from '@phosphor-icons/react'
export function StatusSuffix({ isArchived, isTrashed }: { isArchived: boolean; isTrashed: boolean }) {
if (isTrashed) return <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>
if (isArchived) return <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>
return null
}
export function LinkButton({ label, typeColor, bgColor, isArchived, isTrashed, onClick, onRemove, title, TypeIcon }: {
label: string
typeColor: string
bgColor?: string
isArchived: boolean
isTrashed: boolean
onClick: () => void
onRemove?: () => void
title?: string
TypeIcon: ComponentType<SVGAttributes<SVGSVGElement>>
}) {
const isDimmed = isArchived || isTrashed
const color = isDimmed ? 'var(--muted-foreground)' : typeColor
return (
<button
className={`group/link flex w-full items-center justify-between gap-2 border-none text-left cursor-pointer min-w-0${bgColor ? ' ring-inset hover:ring-1 hover:ring-current' : ' hover:opacity-80'}`}
style={{
background: isDimmed ? 'var(--muted)' : (bgColor ?? 'transparent'),
color, borderRadius: 6, padding: bgColor ? '6px 10px' : '4px 0',
fontSize: 12, fontWeight: 500, opacity: isDimmed ? 0.7 : 1,
}}
onClick={onClick}
title={title}
>
<span className="flex items-center gap-1 flex-1 truncate">
{isTrashed && <Trash size={12} className="shrink-0" />}
{label}
<StatusSuffix isArchived={isArchived} isTrashed={isTrashed} />
</span>
<span className="flex items-center gap-1.5 shrink-0">
{onRemove && (
<span
className="flex items-center opacity-0 transition-opacity group-hover/link:opacity-100"
onClick={(e) => { e.stopPropagation(); onRemove() }}
role="button"
title="Remove from relation"
data-testid="remove-relation-ref"
>
<X size={14} />
</span>
)}
<TypeIcon width={14} height={14} className="shrink-0" style={{ color, opacity: 0.5 }} />
</span>
</button>
)
}

View File

@@ -0,0 +1,59 @@
import { useMemo } from 'react'
import type { VaultEntry } from '../../types'
import { getTypeColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { LinkButton } from './LinkButton'
export interface ReferencedByItem {
entry: VaultEntry
viaKey: string
}
export function ReferencedByPanel({ items, typeEntryMap, onNavigate }: {
items: ReferencedByItem[]
typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
}) {
const grouped = useMemo(() => {
const map = new Map<string, VaultEntry[]>()
for (const item of items) {
const existing = map.get(item.viaKey)
if (existing) existing.push(item.entry)
else map.set(item.viaKey, [item.entry])
}
return Array.from(map.entries())
}, [items])
if (items.length === 0) return null
return (
<div className="referenced-by-panel">
<div className="flex flex-col gap-2.5">
{grouped.map(([viaKey, groupEntries]) => (
<div key={viaKey}>
<span className="mb-1 block font-mono text-muted-foreground" style={{ fontSize: 9, fontWeight: 400, letterSpacing: '1.2px', textTransform: 'uppercase', opacity: 0.7 }}>
{viaKey}
</span>
<div className="flex flex-col gap-0.5">
{groupEntries.map((e) => {
const te = typeEntryMap[e.isA ?? '']
return (
<LinkButton
key={e.path}
label={e.title}
typeColor={getTypeColor(e.isA, te?.color)}
isArchived={e.archived}
isTrashed={e.trashed}
onClick={() => onNavigate(e.title)}
title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined}
TypeIcon={getTypeIcon(e.isA, te?.icon)}
/>
)
})}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,279 @@
import { useMemo, useCallback, useState, useRef } from 'react'
import type { VaultEntry } from '../../types'
import { X } from '@phosphor-icons/react'
import type { ParsedFrontmatter } from '../../utils/frontmatter'
import { RELATIONSHIP_KEYS, containsWikilinks } from '../DynamicPropertiesPanel'
import type { FrontmatterValue } from '../Inspector'
import { NoteSearchList } from '../NoteSearchList'
import { useNoteSearch } from '../../hooks/useNoteSearch'
import { isWikilink, resolveRefProps } from './shared'
import { LinkButton } from './LinkButton'
function SearchDropdown({ search, onSelect }: {
search: ReturnType<typeof useNoteSearch>
onSelect: (title: string) => void
}) {
return (
<div className="absolute left-0 right-0 top-full z-50 mt-0.5 rounded border border-border bg-popover shadow-md">
<NoteSearchList
items={search.results}
selectedIndex={search.selectedIndex}
getItemKey={(item) => item.entry.path}
onItemClick={(item) => onSelect(item.entry.title)}
onItemHover={(i) => search.setSelectedIndex(i)}
className="max-h-[160px] overflow-y-auto"
/>
</div>
)
}
function InlineAddNote({ entries, onAdd }: {
entries: VaultEntry[]
onAdd: (noteTitle: string) => void
}) {
const [active, setActive] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const search = useNoteSearch(entries, query, 8)
const selectAndClose = useCallback((title: string) => {
onAdd(title)
setQuery('')
setActive(false)
}, [onAdd])
const handleConfirm = useCallback(() => {
const title = search.selectedEntry?.title ?? query.trim()
if (title) selectAndClose(title)
}, [search.selectedEntry, query, selectAndClose])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
search.handleKeyDown(e)
if (e.key === 'Enter') { e.preventDefault(); handleConfirm() }
else if (e.key === 'Escape') { setQuery(''); setActive(false) }
}, [search, handleConfirm])
if (!active) {
return (
<button
className="mt-1 w-full border border-dashed border-border bg-transparent text-left text-muted-foreground cursor-pointer hover:border-foreground hover:text-foreground"
style={{ borderRadius: 6, padding: '6px 10px', fontSize: 12 }}
onClick={() => setActive(true)}
data-testid="add-relation-ref"
>
Add
</button>
)
}
return (
<div className="relative mt-1">
<div className="group/add relative flex items-center">
<input
ref={inputRef}
autoFocus
className="w-full border border-border bg-transparent text-foreground"
style={{ borderRadius: 6, outline: 'none', minWidth: 0, padding: '6px 10px', fontSize: 12 }}
placeholder="Note title"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
data-testid="add-relation-ref-input"
/>
<button
className="absolute right-1 top-1/2 -translate-y-1/2 border-none bg-transparent p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/add:opacity-100"
onClick={() => { setQuery(''); setActive(false) }}
>
<X size={12} />
</button>
</div>
{query.trim() && search.results.length > 0 && (
<SearchDropdown search={search} onSelect={selectAndClose} />
)}
</div>
)
}
function RelationshipGroup({ label, refs, entries, typeEntryMap, onNavigate, onRemoveRef, onAddRef }: {
label: string; refs: string[]; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
onRemoveRef?: (ref: string) => void; onAddRef?: (noteTitle: string) => void
}) {
if (refs.length === 0) return null
return (
<div className="mb-2.5">
<span className="font-mono-overline mb-1 block text-muted-foreground">{label}</span>
<div className="flex flex-col gap-1">
{refs.map((ref, idx) => {
const props = resolveRefProps(ref, entries, typeEntryMap)
return (
<LinkButton
key={`${ref}-${idx}`}
{...props}
onClick={() => onNavigate(props.target)}
onRemove={onRemoveRef ? () => onRemoveRef(ref) : undefined}
/>
)
})}
</div>
{onAddRef && <InlineAddNote entries={entries} onAdd={onAddRef} />}
</div>
)
}
function extractRelationshipRefs(frontmatter: ParsedFrontmatter): { key: string; refs: string[] }[] {
return Object.entries(frontmatter)
.filter(([key, value]) => key !== 'Type' && (RELATIONSHIP_KEYS.has(key) || containsWikilinks(value)))
.map(([key, value]) => {
const refs: string[] = []
if (typeof value === 'string' && isWikilink(value)) refs.push(value)
else if (Array.isArray(value)) value.forEach(v => { if (typeof v === 'string' && isWikilink(v)) refs.push(v) })
return { key, refs }
})
.filter(({ refs }) => refs.length > 0)
}
function NoteTargetInput({ entries, value, onChange, onSubmit, onCancel }: {
entries: VaultEntry[]
value: string
onChange: (v: string) => void
onSubmit?: () => void
onCancel?: () => void
}) {
const [focused, setFocused] = useState(false)
const search = useNoteSearch(entries, value, 8)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
search.handleKeyDown(e)
if (e.key === 'Enter') {
e.preventDefault()
if (search.selectedEntry) { onChange(search.selectedEntry.title); setFocused(false) }
else onSubmit?.()
} else if (e.key === 'Escape') { onCancel?.() }
}, [search, onChange, onSubmit, onCancel])
const showDropdown = focused && value.trim() && search.results.length > 0
return (
<div className="relative">
<input
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Note title"
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setTimeout(() => setFocused(false), 150)}
onKeyDown={handleKeyDown}
/>
{showDropdown && (
<SearchDropdown search={search} onSelect={(title) => { onChange(title); setFocused(false) }} />
)}
</div>
)
}
function AddRelationshipForm({ entries, onAddProperty }: {
entries: VaultEntry[]
onAddProperty: (key: string, value: FrontmatterValue) => void
}) {
const [relKey, setRelKey] = useState('')
const [relTarget, setRelTarget] = useState('')
const [showForm, setShowForm] = useState(false)
const keyInputRef = useRef<HTMLInputElement>(null)
const submitForm = useCallback((targetOverride?: string) => {
const key = relKey.trim()
const target = (targetOverride ?? relTarget).trim()
if (!key || !target) return
onAddProperty(key, `[[${target}]]`)
setRelKey(''); setRelTarget(''); setShowForm(false)
}, [relKey, relTarget, onAddProperty])
const resetForm = useCallback(() => {
setShowForm(false); setRelKey(''); setRelTarget('')
}, [])
if (!showForm) {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer' }} onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}>+ Link existing</button>
)
}
return (
<div className="mt-2 flex flex-col gap-1.5">
<input
ref={keyInputRef}
autoFocus
className="w-full border border-border bg-transparent px-2 py-1 text-xs text-foreground"
style={{ borderRadius: 4, outline: 'none' }}
placeholder="Relationship name"
value={relKey}
onChange={e => setRelKey(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submitForm(); else if (e.key === 'Escape') resetForm() }}
/>
<NoteTargetInput entries={entries} value={relTarget} onChange={setRelTarget} onSubmit={submitForm} onCancel={resetForm} />
<div className="flex gap-1.5">
<button className="flex-1 border border-border bg-transparent text-xs text-foreground" style={{ borderRadius: 4, padding: '4px 0' }} onClick={() => submitForm()} disabled={!relKey.trim() || !relTarget.trim()}>Add</button>
<button className="border border-border bg-transparent text-xs text-muted-foreground" style={{ borderRadius: 4, padding: '4px 8px' }} onClick={resetForm}>Cancel</button>
</div>
</div>
)
}
function updateRefsForRemoval(refs: string[], refToRemove: string): FrontmatterValue | null {
const remaining = refs.filter(r => r !== refToRemove)
if (remaining.length === 0) return null
return remaining.length === 1 ? remaining[0] : remaining
}
function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterValue | false {
const newRef = `[[${noteTitle}]]`
if (refs.includes(newRef)) return false
const updated = [...refs, newRef]
return updated.length === 1 ? updated[0] : updated
}
export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap, onNavigate, onAddProperty, onUpdateProperty, onDeleteProperty }: {
frontmatter: ParsedFrontmatter; entries: VaultEntry[]; typeEntryMap: Record<string, VaultEntry>
onNavigate: (target: string) => void
onAddProperty?: (key: string, value: FrontmatterValue) => void
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
}) {
const relationshipEntries = useMemo(() => extractRelationshipRefs(frontmatter), [frontmatter])
const handleRemoveRef = useCallback((key: string, refToRemove: string) => {
if (!onUpdateProperty || !onDeleteProperty) return
const group = relationshipEntries.find(g => g.key === key)
if (!group) return
const result = updateRefsForRemoval(group.refs, refToRemove)
if (result === null) onDeleteProperty(key)
else onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty, onDeleteProperty])
const handleAddRef = useCallback((key: string, noteTitle: string) => {
if (!onUpdateProperty) return
const existing = relationshipEntries.find(g => g.key === key)?.refs ?? []
const result = updateRefsForAddition(existing, noteTitle)
if (result !== false) onUpdateProperty(key, result)
}, [relationshipEntries, onUpdateProperty])
const canEdit = !!onUpdateProperty && !!onDeleteProperty
return (
<div>
{relationshipEntries.map(({ key, refs }) => (
<RelationshipGroup
key={key} label={key} refs={refs} entries={entries} typeEntryMap={typeEntryMap} onNavigate={onNavigate}
onRemoveRef={canEdit ? (ref) => handleRemoveRef(key, ref) : undefined}
onAddRef={canEdit ? (noteTitle) => handleAddRef(key, noteTitle) : undefined}
/>
))}
{onAddProperty
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} />
: <button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
}
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { wikilinkTarget, wikilinkDisplay } from '../../utils/wikilink'
import type { VaultEntry } from '../../types'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { findEntryByTarget } from '../../utils/wikilinkColors'
export function isWikilink(value: string): boolean {
return /^\[\[.*\]\]$/.test(value)
}
export function resolveRef(ref: string, entries: VaultEntry[]): VaultEntry | undefined {
const target = wikilinkTarget(ref)
const byTitle = findEntryByTarget(entries, target)
if (byTitle) return byTitle
const lastSegment = target.split('/').pop()
return entries.find((e) => {
const stem = e.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')
if (stem === target) return true
return e.filename.replace(/\.md$/, '') === lastSegment
})
}
export function entryStatusTitle(entry: VaultEntry | undefined): string | undefined {
if (entry?.trashed) return 'Trashed'
if (entry?.archived) return 'Archived'
return undefined
}
export function resolveRefProps(ref: string, entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>) {
const resolved = resolveRef(ref, entries)
const refType = resolved?.isA ?? null
const te = typeEntryMap[refType ?? '']
return {
label: wikilinkDisplay(ref),
typeColor: getTypeColor(refType, te?.color),
bgColor: getTypeLightColor(refType, te?.color),
isArchived: resolved?.archived ?? false,
isTrashed: resolved?.trashed ?? false,
target: wikilinkTarget(ref),
title: entryStatusTitle(resolved),
TypeIcon: getTypeIcon(refType, te?.icon),
}
}

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