Compare commits

...

29 Commits

Author SHA1 Message Date
Luca Rossi
97d2182c0e test: add asserting wikilink navigation E2E test — screenshot.spec.ts test had no expect() calls (#180)
Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 23:55:57 +01:00
Test
80baa74175 feat: add 'Check for Updates' command to command palette
Users can now trigger an update check from Cmd+K → "Check for Updates"
without leaving the app. Shows toast for up-to-date/error states,
and the existing UpdateBanner for available updates. Command is
disabled while an update is downloading or ready to install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:53:37 +01:00
Test
f71e6d07e7 feat: rename demo vault from 'Demo v2' to 'Getting Started', remove 'Laputa' vault entry
- Rename 'Demo v2' → 'Getting Started' in vault switcher
- Remove 'Laputa' personal vault from default vault list
- Update default Getting Started vault path from Documents/Laputa to Documents/Getting Started
- Update mock handlers and tests to reflect new vault path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:49:36 +01:00
Test
b8a0702e3c fix: use compile-time env!() macro for BUILD_NUMBER instead of runtime std::env::var
The build number was always showing 'b0' because build.rs sets BUILD_NUMBER
via cargo:rustc-env (compile-time), but lib.rs was reading it with
std::env::var (runtime) which falls back to "0" when unset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:42:24 +01:00
Luca Rossi
c48f337c4d fix: bundle mcp-server into release app so AI Chat works (#178)
* feat: bundle mcp-server into release app so AI Chat works

- Add esbuild bundle script (scripts/bundle-mcp-server.mjs) that compiles
  mcp-server/index.js and ws-bridge.js into self-contained CJS bundles
- Output goes to src-tauri/resources/mcp-server/ (gitignored)
- Add Tauri resources config to copy bundles into Contents/Resources/mcp-server/
- Update mcp_server_dir() to look in Contents/Resources/ (not Contents/) in release
- Add bundle-mcp npm script; hook it into tauri beforeBuildCommand
- Exclude generated resources from ESLint

Previously AI Chat showed 'mcp-server not found at .../Contents/mcp-server'
because the release path lacked the 'Resources' segment and no files were bundled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ci: bundle mcp-server resources before Rust tests

* fix: add mcp-server as pnpm workspace package so esbuild can resolve its deps in CI

* fix: exclude src-tauri/target from eslint to fix CI lint failure

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-02 23:31:04 +01:00
Test
b32d46f482 style: fix rustfmt formatting in cache test assertions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:29:27 +01:00
Test
fd100e1c3b fix: sync changes badge count with list by invalidating stale vault cache
When .laputa-cache.json was committed to git, cloned vaults carried
absolute paths from the original machine. The badge (from get_modified_files)
used fresh local paths while the list filtered entries by stale cached paths,
causing a mismatch.

Three-layer fix:
- Invalidate cache when vault_path differs from current machine (CACHE_VERSION bump)
- Exclude .laputa-cache.json via .git/info/exclude to prevent future commits
- Defense-in-depth: match entries by relative path suffix in NoteList
- Surface error message when modified files fetch fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:29:27 +01:00
Test
249db95c9e fix: correct git_uncommitted_files call after rebase conflict 2026-03-02 23:22:31 +01:00
Test
3c27b63908 fix: add aria-label to InlineRenameInput for test accessibility 2026-03-02 23:20:49 +01:00
Test
a246de4483 style: apply rustfmt to cache.rs test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 23:17:36 +01:00
Test
2793024904 feat: add Rename section to sidebar context menu
- Add handleRenameSection to useEntryActions: writes/deletes 'sidebar label'
  frontmatter key on the Type note with optimistic in-memory update
- Add InlineRenameInput to SidebarParts: autoFocus input rendered in-place
  of section title, submits on Enter/blur, cancels on Escape
- Add 'Rename section…' as first item in sidebar section context menu
- Wire onRenameSection from App.tsx through Sidebar props
- Add 10 new tests (4 unit, 6 component) covering the full rename flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 23:17:36 +01:00
Test
f0ef9cacec fix: update_same_commit picks up modified files, not only new ones
The cache invalidation only re-parsed new untracked files when the git
HEAD hash was unchanged. Modified (uncommitted) files were served stale,
so editing a Type note's 'sidebar label' frontmatter key had no effect
until the next git commit triggered a full incremental diff.

Replace git_uncommitted_new_files (status ?? / A only) with
git_uncommitted_files (all porcelain entries) and apply the same
remove-stale + re-parse logic used by update_different_commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 23:17:26 +01:00
Test
d2538e121f style: apply rustfmt to theme.rs and vault/cache.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:12:36 +01:00
Test
531666b031 fix: remove unused is_new_file_status function
Clippy flagged dead code in vault/cache.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:11:30 +01:00
Test
e239d71a48 fix: add isDark to useThemeManager return value 2026-03-02 23:09:02 +01:00
Test
e2f1fe239e fix: remove stale deriveThemeVariables call and update test assertions after conflict resolution 2026-03-02 23:07:58 +01:00
Test
8495f0e2e6 test: add theme command registry tests and minor fixes 2026-03-02 23:06:12 +01:00
Test
19bc3c67e3 wip: themes-editable — theme management system WIP (13 files, 1014 insertions) 2026-03-02 23:06:12 +01:00
Test
628d97d909 chore: sync Sidebar.tsx dragHandleProps removal from main 2026-03-02 21:57:43 +01:00
Test
aa6b31317c chore: sync SidebarParts dragHandle removal from main 2026-03-02 21:57:43 +01:00
Test
b24ba8a4c6 fix: position context menu and customize popover at right-click coordinates
The CustomizeIconColor popover was hardcoded to (20, 100) — always wrong.
Bottom sections also failed because the context menu could extend below the
viewport, making "Customize icon & color" unreachable.

- Capture context menu click position and use it to position the customize
  popover (via new `customizePos` state in Sidebar)
- Clamp context menu position to flip above the click point when near the
  bottom of the viewport (CONTEXT_MENU_HEIGHT guard)
- Clamp customize popover to stay within viewport bounds via clampToViewport()
- Add 6 tests covering context menu flow, positioning, and color callback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:57:43 +01:00
Test
b3f47f4507 chore: remove dragHandleProps from SectionContent destructuring 2026-03-02 21:56:50 +01:00
Test
6fcd0552d0 chore: remove remaining dragHandleProps from SortableSection 2026-03-02 21:55:31 +01:00
Test
8747a84453 style: remove drag handle icon from sidebar sections 2026-03-02 21:29:16 +01:00
Test
2eb3080050 style: align section icons with main nav items in sidebar 2026-03-02 21:29:16 +01:00
Test
99b64c0fac style: remove letter-spacing from status/property labels in inspector 2026-03-02 21:29:16 +01:00
Test
b2a68a9a67 style: reduce editor body font to 15px, apply -0.5 letter-spacing to h2/h3/h4 2026-03-02 21:29:16 +01:00
Test
f5bbcb81a4 fix: defer H1 selection to second rAF so content swap completes first
The previous implementation called selectFirstHeading() in the same rAF
as editor.focus(), but the new note's content (applied via queueMicrotask
inside a React effect) hadn't been swapped in yet. React's MessageChannel
scheduler runs the re-render between animation frames, so the heading
block didn't exist in the TipTap document when the selection ran.

Fix: move selectFirstHeading() into a nested requestAnimationFrame inside
doFocus(). Between rAF 1 (focus) and rAF 2 (select), all pending
macrotasks — React's re-render + the queueMicrotask content swap — run
to completion, guaranteeing the H1 block is in the document before we
try to select it.

Updated tests: add rAF mock to the timeout-path select test (which now
needs it since selection is deferred via rAF); add a regression test
that explicitly verifies focus in rAF1 and selection in rAF2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:27:26 +01:00
Test
db5e53f77e fix: register Cmd+\ keyboard shortcut to toggle raw editor mode
Cmd+\ was not wired to the raw editor toggle. Added onToggleRawEditor
to KeyboardActions interface and mapped '\\' in the cmdKeyMap so the
shortcut fires handleToggleRaw via the existing rawToggleRef.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 21:02:54 +01:00
48 changed files with 2609 additions and 628 deletions

View File

@@ -1 +1 @@
81859
21351

View File

@@ -60,6 +60,9 @@ jobs:
- name: Run frontend tests
run: pnpm test
- name: Bundle MCP server resources (required by Tauri build)
run: node scripts/bundle-mcp-server.mjs
- name: Run Rust tests
run: cargo test --manifest-path=src-tauri/Cargo.toml

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ final_selection.py
.claude-done
.claude-blocked
src-tauri/target
# Generated mcp-server bundle (built by scripts/bundle-mcp-server.mjs)
src-tauri/resources/

View File

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

View File

@@ -231,3 +231,30 @@ test('full create note flow', async ({ page }) => {
await page.screenshot({ path: 'test-results/core-create-note.png', fullPage: true })
})
// --- Flow 8: Wiki-link navigation ---
test('clicking a wikilink opens the target note in a new tab', async ({ page }) => {
// Open "Manage Sponsorships" which contains [[Matteo Cellini]] wikilink
await page.locator('.note-list__item', { hasText: 'Manage Sponsorships' }).click()
await page.waitForTimeout(300)
// Verify we opened the right note
await expect(page.locator('.editor__tab--active')).toHaveText(/Manage Sponsorships/)
// Click the wikilink — use mouse.click to fire real mousedown
const wikilink = page.locator('.cm-wikilink', { hasText: 'Matteo Cellini' })
await expect(wikilink).toBeVisible()
const box = await wikilink.boundingBox()
expect(box).not.toBeNull()
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
await page.waitForTimeout(300)
// New tab should open with the target note active
await expect(page.locator('.editor__tab--active')).toHaveText(/Matteo Cellini/)
// Editor should show the target note's content
await expect(page.locator('.cm-content')).toContainText('Matteo Cellini')
await page.screenshot({ path: 'test-results/core-wikilink-nav.png', fullPage: true })
})

View File

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

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"bundle-mcp": "node scripts/bundle-mcp-server.mjs",
"lint": "eslint .",
"preview": "vite preview",
"tauri": "tauri",
@@ -64,6 +65,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"esbuild": "^0.27.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",

708
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,5 @@
packages:
- mcp-server
ignoredBuiltDependencies:
- esbuild

View File

@@ -0,0 +1,45 @@
/**
* Bundle the mcp-server Node.js files into self-contained CJS bundles
* that can be shipped as Tauri resources inside the .app bundle.
*
* Output: src-tauri/resources/mcp-server/{index.js,ws-bridge.js}
*/
import { build } from 'esbuild'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { mkdirSync, writeFileSync } from 'fs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = join(__dirname, '..')
const SRC = join(ROOT, 'mcp-server')
const OUT = join(ROOT, 'src-tauri', 'resources', 'mcp-server')
mkdirSync(OUT, { recursive: true })
// Tell Node.js that this directory contains CJS bundles, even if the
// root package.json declares "type": "module".
writeFileSync(join(OUT, 'package.json'), JSON.stringify({ type: 'commonjs' }))
const shared = {
platform: 'node',
bundle: true,
format: 'cjs',
target: 'node18',
// Mark optional native bindings as external — ws works fine without them
external: ['bufferutil', 'utf-8-validate'],
logLevel: 'warning',
}
await build({
...shared,
entryPoints: [join(SRC, 'index.js')],
outfile: join(OUT, 'index.js'),
})
await build({
...shared,
entryPoints: [join(SRC, 'ws-bridge.js')],
outfile: join(OUT, 'ws-bridge.js'),
})
console.log('mcp-server bundled → src-tauri/resources/mcp-server/')

View File

@@ -114,10 +114,7 @@ fn git_commit(vault_path: String, message: String) -> Result<String, String> {
#[tauri::command]
fn get_build_number() -> String {
{
let n = std::env::var("BUILD_NUMBER").unwrap_or_else(|_| "0".to_string());
format!("b{}", n)
}
format!("b{}", env!("BUILD_NUMBER"))
}
#[tauri::command]
@@ -372,6 +369,12 @@ fn create_theme(vault_path: String, source_id: Option<String>) -> Result<String,
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),
@@ -398,8 +401,10 @@ fn run_startup_tasks() {
vault::migrate_is_a_to_type(vp_str),
);
// Seed _themes/ with built-in themes if missing
// 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);
// Register Laputa MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
@@ -452,6 +457,7 @@ mod tests {
"expected 'b' prefix, got: {}",
result
);
assert_ne!(result, "b0", "build number should not fall back to 0");
}
}
@@ -549,7 +555,8 @@ pub fn run() {
get_vault_settings,
save_vault_settings,
set_active_theme,
create_theme
create_theme,
create_vault_theme
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -27,10 +27,12 @@ pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
}
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
// On macOS the exe lives at Contents/MacOS/<binary>.
// Resources are placed at Contents/Resources/ by Tauri.
let release_path = exe
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("mcp-server"))
.map(|p| p.join("Resources").join("mcp-server"))
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
if release_path.join("ws-bridge.js").exists() {
return Ok(release_path);

View File

@@ -116,20 +116,112 @@ pub fn get_theme(vault_path: &str, theme_id: &str) -> Result<ThemeFile, String>
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) {
let themes_dir = Path::new(vault_path).join("_themes");
if themes_dir.is_dir() {
return;
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();
}
if fs::create_dir_all(&themes_dir).is_err() {
return;
for i in 2.. {
let candidate = format!("{base}-{i}");
if !dir.join(format!("{candidate}.{ext}")).exists() {
return candidate;
}
}
let _ = fs::write(themes_dir.join("default.json"), DEFAULT_THEME);
let _ = fs::write(themes_dir.join("dark.json"), DARK_THEME);
let _ = fs::write(themes_dir.join("minimal.json"), MINIMAL_THEME);
log::info!("Seeded _themes/ with built-in themes");
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).
@@ -139,7 +231,7 @@ pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String,
fs::create_dir_all(&themes_dir)
.map_err(|e| format!("Failed to create _themes directory: {e}"))?;
let new_id = find_available_id(&themes_dir, "untitled");
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"));
@@ -169,20 +261,6 @@ pub fn create_theme(vault_path: &str, source_id: Option<&str>) -> Result<String,
Ok(new_id)
}
/// Find a filename that doesn't conflict (untitled, untitled-2, untitled-3, ...).
fn find_available_id(dir: &Path, base: &str) -> String {
if !dir.join(format!("{base}.json")).exists() {
return base.to_string();
}
for i in 2.. {
let candidate = format!("{base}-{i}");
if !dir.join(format!("{candidate}.json")).exists() {
return candidate;
}
}
unreachable!()
}
/// Generate the default light theme JSON.
fn default_theme_json(name: &str) -> String {
serde_json::to_string_pretty(&serde_json::json!({
@@ -312,6 +390,233 @@ pub const MINIMAL_THEME: &str = r##"{
}
}"##;
/// 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::*;
@@ -462,4 +767,88 @@ mod tests {
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

@@ -7,12 +7,16 @@ 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 = 2;
const CACHE_VERSION: u32 = 3;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {
#[serde(default = "default_cache_version")]
version: u32,
/// The vault path when the cache was written. Used to detect stale caches
/// from a different machine or a moved vault directory.
#[serde(default)]
vault_path: String,
commit_hash: String,
entries: Vec<VaultEntry>,
}
@@ -50,11 +54,6 @@ fn parse_porcelain_line(line: &str) -> Option<(&str, String)> {
Some((&line[..2], line[3..].trim().to_string()))
}
/// Check if a porcelain status indicates a new/untracked file.
fn is_new_file_status(status: &str) -> bool {
status == "??" || status.starts_with('A')
}
/// Extract .md file paths from git diff --name-only output.
fn collect_md_paths_from_diff(stdout: &str) -> Vec<String> {
stdout
@@ -80,11 +79,15 @@ 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();
let uncommitted = run_git(vault, &["status", "--porcelain"])
// Use ls-files for untracked files so that newly-seeded directories are picked up
// as individual files rather than as a single "?? dirname/" entry.
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 {
for path in uncommitted.into_iter().chain(modified) {
if !files.contains(&path) {
files.push(path);
}
@@ -93,17 +96,10 @@ fn git_changed_files(vault: &Path, from_hash: &str, to_hash: &str) -> Vec<String
files
}
fn git_uncommitted_new_files(vault: &Path) -> Vec<String> {
let stdout = match run_git(vault, &["status", "--porcelain"]) {
Some(s) => s,
None => return Vec::new(),
};
stdout
.lines()
.filter_map(parse_porcelain_line)
.filter(|(status, path)| path.ends_with(".md") && is_new_file_status(status))
.map(|(_, path)| path)
.collect()
fn git_uncommitted_files(vault: &Path) -> Vec<String> {
run_git(vault, &["status", "--porcelain"])
.map(|s| collect_md_paths_from_porcelain(&s))
.unwrap_or_default()
}
fn load_cache(vault: &Path) -> Option<VaultCache> {
@@ -115,6 +111,7 @@ fn write_cache(vault: &Path, cache: &VaultCache) {
if let Ok(data) = serde_json::to_string(cache) {
let _ = fs::write(cache_path(vault), data);
}
ensure_cache_excluded(vault);
}
/// Normalize an absolute path to a relative path for comparison with git output.
@@ -143,6 +140,23 @@ 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.
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"));
}
}
/// Sort entries by modified_at descending and write the cache.
fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String) -> Vec<VaultEntry> {
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
@@ -150,6 +164,7 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
vault,
&VaultCache {
version: CACHE_VERSION,
vault_path: vault.to_string_lossy().to_string(),
commit_hash: hash,
entries: entries.clone(),
},
@@ -157,23 +172,19 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
entries
}
/// Handle same-commit cache hit: add any uncommitted new files.
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
let new_files = git_uncommitted_new_files(vault);
let mut entries = cache.entries;
let existing: std::collections::HashSet<String> = entries
.iter()
.map(|e| to_relative_path(&e.path, vault))
.collect();
let new_entries = parse_files_at(vault, &new_files);
for entry in new_entries {
let rel = to_relative_path(&entry.path, vault);
if !existing.contains(&rel) {
entries.push(entry);
}
let changed = git_uncommitted_files(vault);
if changed.is_empty() {
return cache.entries;
}
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
let mut entries: Vec<VaultEntry> = cache
.entries
.into_iter()
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
.collect();
entries.extend(parse_files_at(vault, &changed));
finalize_and_cache(vault, entries, cache.commit_hash)
}
@@ -212,7 +223,10 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
};
if let Some(cache) = load_cache(vault_path) {
if cache.version != CACHE_VERSION {
let current_vault_str = vault_path.to_string_lossy();
let cache_stale = cache.version != CACHE_VERSION
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref());
if cache_stale {
let entries = scan_vault(vault_path)?;
return Ok(finalize_and_cache(vault_path, entries, current_hash));
}
@@ -300,6 +314,71 @@ mod tests {
assert_eq!(entries2[0].title, "Note");
}
#[test]
fn test_scan_vault_cached_invalidates_stale_vault_path() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
// Init git repo
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, "note.md", "# Note\n\nContent.");
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();
// Build cache normally
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0]
.path
.starts_with(&vault.to_string_lossy().as_ref()),
"Entry path should start with vault path"
);
// Tamper with cache to simulate a clone from a different machine
let cache_file = cache_path(vault);
let cache_data = fs::read_to_string(&cache_file).unwrap();
let tampered = cache_data.replace(
&vault.to_string_lossy().as_ref(),
"/Users/other-machine/OtherVault",
);
fs::write(&cache_file, tampered).unwrap();
// Rescanning should invalidate the stale cache and produce correct paths
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
assert!(
entries2[0]
.path
.starts_with(&vault.to_string_lossy().as_ref()),
"After stale-cache invalidation, paths should use the current vault path, got: {}",
entries2[0].path
);
}
#[test]
fn test_scan_vault_cached_incremental_different_commit() {
let dir = TempDir::new().unwrap();
@@ -358,4 +437,108 @@ mod tests {
assert!(titles.contains(&"First"));
assert!(titles.contains(&"Second"));
}
#[test]
fn test_update_same_commit_picks_up_modified_file() {
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();
// Commit a type note without sidebar label
create_test_file(vault, "type/news.md", "---\ntype: Type\n---\n# News\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 the cache (same commit hash)
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].sidebar_label, None);
// User edits the type note to add sidebar label (uncommitted)
create_test_file(
vault,
"type/news.md",
"---\ntype: Type\nsidebar label: News\n---\n# News\n",
);
// Reload with same git HEAD — must pick up the modification
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
assert_eq!(
entries2[0].sidebar_label,
Some("News".to_string()),
"sidebarLabel must reflect the uncommitted edit"
);
}
#[test]
fn test_update_same_commit_new_file_still_added() {
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 new untracked file
create_test_file(vault, "new-note.md", "# New Note\n");
// Cache still same commit — new untracked file must appear
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 2);
let titles: Vec<&str> = entries2.iter().map(|e| e.title.as_str()).collect();
assert!(titles.contains(&"Existing"));
assert!(titles.contains(&"New Note"));
}
}

View File

@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
/// Default location for the Getting Started vault.
pub fn default_vault_path() -> Result<PathBuf, String> {
dirs::document_dir()
.map(|d| d.join("Laputa"))
.map(|d| d.join("Getting Started"))
.ok_or_else(|| "Could not determine Documents directory".to_string())
}
@@ -423,7 +423,7 @@ mod tests {
let path = default_vault_path().unwrap();
let path_str = path.to_string_lossy();
assert!(path_str.contains("Documents"));
assert!(path_str.ends_with("Laputa"));
assert!(path_str.ends_with("Getting Started"));
}
#[test]

View File

@@ -5,9 +5,9 @@
"identifier": "club.refactoring.laputa",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5201",
"devUrl": "http://localhost:5202",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build"
"beforeBuildCommand": "pnpm build && pnpm bundle-mcp"
},
"app": {
"withGlobalTauri": true,
@@ -37,6 +37,9 @@
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"resources": {
"resources/mcp-server/**/*": "mcp-server/"
},
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View File

@@ -71,7 +71,7 @@ const mockCommandResults: Record<string, unknown> = {
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
save_settings: null,
check_vault_exists: true,
get_default_vault_path: '/Users/mock/Documents/Laputa',
get_default_vault_path: '/Users/mock/Documents/Getting Started',
list_themes: [],
get_vault_settings: { theme: null },
}

View File

@@ -123,7 +123,7 @@ function App() {
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath)
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent)
useMcpRegistration(resolvedPath, setToastMessage)
@@ -277,6 +277,18 @@ function App() {
const zoom = useZoom()
const buildNumber = useBuildNumber()
const { status: updateStatus, actions: updateActions } = useUpdater()
const handleCheckForUpdates = useCallback(async () => {
const result = await updateActions.checkForUpdates()
if (result === 'up-to-date') {
setToastMessage("You're on the latest version")
} else if (result === 'error') {
setToastMessage('Could not check for updates')
}
// 'available' → UpdateBanner handles it automatically
}, [updateActions, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
@@ -303,13 +315,21 @@ function App() {
canGoBack: navHistory.canGoBack, canGoForward: navHistory.canGoForward,
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => { await themeManager.createTheme() },
onCreateTheme: async () => {
await themeManager.createTheme()
await vault.reloadVault()
setSelection({ kind: 'sectionGroup', type: 'Theme' })
},
onOpenTheme: (themeId: string) => {
const entry = vault.entries.find(e => e.path === themeId)
if (entry) notes.handleSelectNote(entry)
},
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
isUpdating: updateStatus.state === 'downloading' || updateStatus.state === 'ready',
})
const { status: updateStatus, actions: updateActions } = useUpdater()
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
// Show welcome/onboarding screen when vault doesn't exist
@@ -347,7 +367,7 @@ 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} 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} modifiedCount={vault.modifiedFiles.length} onCommitPush={commitFlow.openCommitDialog} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>
@@ -355,7 +375,7 @@ function App() {
{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} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
<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} />
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>

View File

@@ -51,7 +51,7 @@ export function CommandPalette({ open, commands, onClose }: CommandPaletteProps)
useEffect(() => {
if (open) {
setQuery('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on open
setSelectedIndex(0)
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open])

View File

@@ -129,7 +129,7 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '1.2px',
letterSpacing: '0',
textTransform: 'uppercase' as const,
overflow: 'hidden',
whiteSpace: 'nowrap' as const,

View File

@@ -996,6 +996,33 @@ describe('NoteList — virtual list with large datasets', () => {
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('matches entries by relative path suffix when absolute paths differ (cross-machine)', () => {
// Simulate a cloned vault where cached entries have paths from a different machine
const crossMachineEntries: VaultEntry[] = mockEntries.map((e) => ({
...e,
path: e.path.replace('/Users/luca/Laputa', '/Users/other-machine/OtherVault'),
}))
const modifiedFromCurrentMachine = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[1].path, relativePath: 'note/facebook-ads-strategy.md', status: 'modified' as const },
]
render(
<NoteList entries={crossMachineEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFromCurrentMachine} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// Even though absolute paths differ, entries should match via relative path suffix
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('shows error message when modifiedFilesError is set', () => {
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={[]} modifiedFilesError="git status failed: not a git repository" onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText(/Failed to load changes/)).toBeInTheDocument()
expect(screen.getByText(/git status failed/)).toBeInTheDocument()
})
it('shows untracked (new) notes alongside modified notes in changes view', () => {
const mixedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },

View File

@@ -26,6 +26,7 @@ interface NoteListProps {
selectedNote: VaultEntry | null
allContent: Record<string, string>
modifiedFiles?: ModifiedFile[]
modifiedFilesError?: string | null
getNoteStatus?: (path: string) => NoteStatus
sidebarCollapsed?: boolean
onSelectNote: (entry: VaultEntry) => void
@@ -143,13 +144,13 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, virtuosoRef }: {
isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
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 ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
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 hasHeader = isTrashView && expiredTrashCount > 0
if (searched.length === 0) {
@@ -232,10 +233,15 @@ function toggleSetMember<T>(set: Set<T>, member: T): Set<T> {
interface NoteListDataParams {
entries: VaultEntry[]; selection: SidebarSelection; allContent: Record<string, string>
query: string; listSort: SortOption; listDirection: SortDirection
modifiedPathSet: Set<string>
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
}
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet }: NoteListDataParams) {
function isModifiedEntry(path: string, pathSet: Set<string>, suffixes: string[]): boolean {
if (pathSet.has(path)) return true
return suffixes.some((suffix) => path.endsWith(suffix))
}
function useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
@@ -248,12 +254,12 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
const searched = useMemo(() => {
if (isEntityView) return []
if (isChangesView) {
const sorted = [...entries.filter((e) => modifiedPathSet.has(e.path))].sort(getSortComparator(listSort, listDirection))
const sorted = [...entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))].sort(getSortComparator(listSort, listDirection))
return filterByQuery(sorted, query)
}
const sorted = [...filterEntries(entries, selection)].sort(getSortComparator(listSort, listDirection))
return filterByQuery(sorted, query)
}, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet])
}, [entries, selection, isEntityView, isChangesView, listSort, listDirection, query, modifiedPathSet, modifiedSuffixes])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
@@ -273,7 +279,7 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
const defaultGetNoteStatus = (): NoteStatus => 'clean'
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -285,6 +291,14 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
[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],
@@ -303,7 +317,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const listSort = listConfig.option
const listDirection = listConfig.direction
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet })
const { isEntityView, isTrashView, isChangesView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const noteListKeyboard = useNoteListKeyboard({
items: searched,
@@ -401,7 +415,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
{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} />
) : (
<ListView isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
<ListView isTrashView={isTrashView} isChangesView={isChangesView} changesError={modifiedFilesError} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} virtuosoRef={noteListKeyboard.virtuosoRef} />
)}
</div>

View File

@@ -209,7 +209,6 @@ function SearchContent({
onMouseEnter={() => onHover(i)}
>
<div className="flex items-center gap-2">
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">{result.title}</span>
{noteType && (

View File

@@ -809,11 +809,67 @@ describe('Sidebar', () => {
expect(topicsIdx).toBeLessThan(peopleIdx)
})
it('renders drag handle on section headers', () => {
it('does not render drag handle icons on section headers', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const dragHandles = screen.getAllByLabelText(/^Drag to reorder/)
// Should have one drag handle per visible section group
expect(dragHandles.length).toBeGreaterThan(0)
const dragHandles = screen.queryAllByLabelText(/^Drag to reorder/)
expect(dragHandles.length).toBe(0)
})
})
describe('rename section via context menu', () => {
it('shows Rename section option in context menu on right-click', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
expect(screen.getByText('Rename section…')).toBeInTheDocument()
})
it('shows Customize icon option in context menu on right-click', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
expect(screen.getByText('Customize icon & color…')).toBeInTheDocument()
})
it('shows inline input when Rename section is clicked', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
fireEvent.click(screen.getByText('Rename section…'))
expect(screen.getByRole('textbox', { name: 'Section name' })).toBeInTheDocument()
})
it('inline input is pre-filled with current label', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
fireEvent.click(screen.getByText('Rename section…'))
const input = screen.getByRole('textbox', { name: 'Section name' }) as HTMLInputElement
expect(input.value).toBe('Projects')
})
it('calls onRenameSection with new name on Enter', () => {
const onRenameSection = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
fireEvent.click(screen.getByText('Rename section…'))
const input = screen.getByRole('textbox', { name: 'Section name' })
fireEvent.change(input, { target: { value: 'My Projects' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onRenameSection).toHaveBeenCalledWith('Project', 'My Projects')
})
it('cancels rename on Escape and hides input', () => {
const onRenameSection = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onRenameSection={onRenameSection} />)
const projectHeader = screen.getByText('Projects').closest('div')!
fireEvent.contextMenu(projectHeader)
fireEvent.click(screen.getByText('Rename section…'))
const input = screen.getByRole('textbox', { name: 'Section name' })
fireEvent.keyDown(input, { key: 'Escape' })
expect(onRenameSection).not.toHaveBeenCalled()
expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument()
})
})
})

View File

@@ -34,6 +34,7 @@ interface SidebarProps {
onCustomizeType?: (typeName: string, icon: string, color: string) => void
onUpdateTypeTemplate?: (typeName: string, template: string) => void
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
onRenameSection?: (typeName: string, label: string) => void
modifiedCount?: number
onCommitPush?: () => void
onCollapse?: () => void
@@ -161,12 +162,13 @@ function applyCustomization(
function SortableSection({ group, sectionProps }: {
group: SectionGroup
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'dragHandleProps' | 'onToggle'>
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void }
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, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
const { attributes, 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
return (
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '4px 6px' }} {...attributes}>
@@ -176,7 +178,10 @@ 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}
onRenameCancel={sectionProps.onRenameCancel}
/>
</div>
)
@@ -216,16 +221,21 @@ function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
)
}
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize, onStartRename }: {
pos: { x: number; y: number } | null; type: string | null
innerRef: React.Ref<HTMLDivElement>
onOpenCustomize: (type: string) => void
onStartRename: (type: string) => void
}) {
if (!pos || !type) return null
const btnClass = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left"
return (
<div ref={innerRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: pos.x, top: pos.y, minWidth: 180 }}>
<button className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left" onClick={() => onOpenCustomize(type)}>
Customize icon & color
<button className={btnClass} onClick={() => onStartRename(type)}>
Rename section
</button>
<button className={btnClass} onClick={() => onOpenCustomize(type)}>
Customize icon &amp; color
</button>
</div>
)
@@ -258,11 +268,14 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
export const Sidebar = memo(function Sidebar({
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
modifiedCount = 0, onCommitPush, onCollapse,
}: SidebarProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
const [renamingType, setRenamingType] = useState<string | null>(null)
const [renameInitialValue, setRenameInitialValue] = useState('')
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
const [showCustomize, setShowCustomize] = useState(false)
@@ -303,6 +316,20 @@ export const Sidebar = memo(function Sidebar({
setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type)
}, [])
const cancelRename = useCallback(() => setRenamingType(null), [])
const handleStartRename = useCallback((type: string) => {
closeContextMenu()
const group = allSectionGroups.find((g) => g.type === type)
setRenameInitialValue(group?.label ?? type)
setRenamingType(type)
}, [closeContextMenu, allSectionGroups])
const handleRenameSubmit = useCallback((value: string) => {
if (renamingType) onRenameSection?.(renamingType, value)
setRenamingType(null)
}, [renamingType, onRenameSection])
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value)
}, [customizeTarget, typeEntryMap, onCustomizeType])
@@ -314,6 +341,7 @@ export const Sidebar = memo(function Sidebar({
const sectionProps = {
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
onContextMenu: handleContextMenu, onToggle: toggleSection,
renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename,
}
return (
@@ -354,7 +382,7 @@ export const Sidebar = memo(function Sidebar({
</nav>
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onChangeTemplate={handleChangeTemplate} onClose={closeCustomizeTarget} />
</aside>
)

View File

@@ -1,7 +1,7 @@
import { type ComponentType } from 'react'
import { type ComponentType, useState, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { cn } from '@/lib/utils'
import { ChevronRight, ChevronDown, Plus, GripVertical } from 'lucide-react'
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
@@ -75,7 +75,10 @@ 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
onRenameCancel?: () => void
}
function childSelection(type: string, entry: VaultEntry): SidebarSelection {
@@ -90,7 +93,8 @@ function resolveCreateHandler(type: string, onCreateType?: (type: string) => voi
export function SectionContent({
group, items, isCollapsed, selection, onSelect, onSelectNote,
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
onCreateType, onCreateNewType, onContextMenu, onToggle,
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
const sectionColor = getTypeColor(type, customColor)
@@ -109,7 +113,10 @@ export function SectionContent({
onContextMenu={(e) => onContextMenu(e, type)}
onToggle={onToggle}
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
dragHandleProps={dragHandleProps}
isRenaming={isRenaming}
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
onRenameCancel={onRenameCancel}
/>
{!isCollapsed && items.length > 0 && (
<SectionChildList
@@ -145,29 +152,67 @@ function SectionChildList({ items, type, selection, sectionColor, sectionLightCo
)
}
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps }: {
function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
initialValue: string
onSubmit: (value: string) => void
onCancel: () => void
}) {
const [value, setValue] = useState(initialValue)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onSubmit(value.trim()) }
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel() }
}
return (
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onSubmit(value.trim())}
onClick={(e) => e.stopPropagation()}
aria-label="Section name"
className="flex-1 rounded border border-primary bg-background text-[13px] font-medium text-foreground outline-none"
style={{ padding: '1px 4px' }}
/>
)
}
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, 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
}) {
return (
<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 6px', borderRadius: 4, gap: 4 }}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
onClick={() => {
if (isRenaming) return
if (isCollapsed) { onToggle(); onSelect() }
else if (isActive) { onToggle() }
else { onSelect() }
}} onContextMenu={onContextMenu}
}} onContextMenu={isRenaming ? undefined : onContextMenu}
>
<div className="flex items-center" style={{ gap: 4 }}>
<div className="flex shrink-0 items-center justify-center text-muted-foreground opacity-0 group-hover/section:opacity-50 hover:!opacity-100 cursor-grab" style={{ width: 16, height: 16 }} {...dragHandleProps} aria-label={`Drag to reorder ${label}`}>
<GripVertical size={12} />
</div>
<Icon size={16} style={{ color: sectionColor }} />
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<Icon size={16} style={{ color: sectionColor, flexShrink: 0 }} />
{isRenaming && onRenameSubmit && onRenameCancel ? (
<InlineRenameInput
key={`rename-${type}`}
initialValue={renameInitialValue ?? label}
onSubmit={onRenameSubmit}
onCancel={onRenameCancel}
/>
) : (
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
)}
</div>
<div className="flex items-center" style={{ gap: 2 }}>
{showCreate && (

View File

@@ -16,7 +16,7 @@ export function StatusPill({ status, className }: { status: string; className?:
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '1.2px',
letterSpacing: '0',
textTransform: 'uppercase' as const,
maxWidth: 160,
}}
@@ -101,7 +101,7 @@ const SECTION_LABEL_STYLE = {
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 9,
fontWeight: 500,
letterSpacing: '1.2px',
letterSpacing: '0',
textTransform: 'uppercase' as const,
}

View File

@@ -51,8 +51,11 @@ interface AppCommandsConfig {
activeThemeId?: string | null
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
onOpenTheme?: (themeId: string) => void
onOpenVault?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
isUpdating?: boolean
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -88,6 +91,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
onToggleAIChat: config.onToggleAIChat,
onToggleRawEditor: config.onToggleRawEditor,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
})
@@ -146,8 +150,11 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
activeThemeId: config.activeThemeId,
onSwitchTheme: config.onSwitchTheme,
onCreateTheme: config.onCreateTheme,
onOpenTheme: config.onOpenTheme,
onOpenVault: config.onOpenVault,
onToggleAIChat: config.onToggleAIChat,
onCheckForUpdates: config.onCheckForUpdates,
isUpdating: config.isUpdating,
})
useKeyboardNavigation({

View File

@@ -18,6 +18,7 @@ interface KeyboardActions {
onGoBack?: () => void
onGoForward?: () => void
onToggleAIChat?: () => void
onToggleRawEditor?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
}
@@ -63,7 +64,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
export function useAppKeyboard({
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, activeTabPathRef, handleCloseTabRef,
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, activeTabPathRef, handleCloseTabRef,
}: KeyboardActions) {
useEffect(() => {
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
@@ -89,6 +90,7 @@ export function useAppKeyboard({
'-': onZoomOut,
'0': onZoomReset,
i: () => onToggleAIChat?.(),
'\\': () => onToggleRawEditor?.(),
}
const handleKeyDown = (e: KeyboardEvent) => {
@@ -104,5 +106,5 @@ export function useAppKeyboard({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat])
}, [onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote, activeTabPathRef, handleCloseTabRef, onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor])
}

View File

@@ -281,6 +281,41 @@ describe('useCommandRegistry', () => {
expect(onOpenDailyNote).toHaveBeenCalled()
})
describe('check-updates command', () => {
it('has check-updates command in Settings group', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
const cmd = result.current.find(c => c.id === 'check-updates')
expect(cmd).toBeDefined()
expect(cmd!.label).toBe('Check for Updates')
expect(cmd!.group).toBe('Settings')
expect(cmd!.keywords).toContain('update')
expect(cmd!.keywords).toContain('version')
})
it('is enabled when not updating', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ isUpdating: false })),
)
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(true)
})
it('is disabled when updating', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ isUpdating: true })),
)
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(false)
})
it('calls onCheckForUpdates when executed', () => {
const onCheckForUpdates = vi.fn()
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ onCheckForUpdates })),
)
result.current.find(c => c.id === 'check-updates')!.execute()
expect(onCheckForUpdates).toHaveBeenCalled()
})
})
describe('type-aware commands', () => {
it('generates "New [Type]" commands from vault entries', () => {
const entries = [
@@ -437,6 +472,46 @@ describe('useCommandRegistry', () => {
const groups = new Set(result.current.map(c => c.group))
expect(groups).toContain('Appearance')
})
it('generates open-theme commands for each theme when onOpenTheme is provided', () => {
const onOpenTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
})))
const openDefault = result.current.find(c => c.id === 'open-theme-default')
const openDark = result.current.find(c => c.id === 'open-theme-dark')
expect(openDefault).toBeDefined()
expect(openDefault!.label).toBe('Edit Default Theme')
expect(openDefault!.group).toBe('Appearance')
expect(openDefault!.enabled).toBe(true)
expect(openDark).toBeDefined()
expect(openDark!.label).toBe('Edit Dark Theme')
})
it('omits open-theme commands when onOpenTheme is not provided', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default',
})))
expect(result.current.find(c => c.id === 'open-theme-default')).toBeUndefined()
expect(result.current.find(c => c.id === 'open-theme-dark')).toBeUndefined()
})
it('calls onOpenTheme with correct themeId when open-theme command executes', () => {
const onOpenTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
})))
result.current.find(c => c.id === 'open-theme-dark')!.execute()
expect(onOpenTheme).toHaveBeenCalledWith('dark')
})
it('open-theme command is always enabled regardless of active theme', () => {
const onOpenTheme = vi.fn()
const { result } = renderHook(() => useCommandRegistry(makeConfig({
themes: themeFixtures, activeThemeId: 'default', onOpenTheme,
})))
expect(result.current.find(c => c.id === 'open-theme-default')!.enabled).toBe(true)
})
})
})

View File

@@ -34,6 +34,8 @@ interface CommandRegistryConfig {
onToggleInspector: () => void
onToggleRawEditor?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
isUpdating?: boolean
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
@@ -49,6 +51,7 @@ interface CommandRegistryConfig {
activeThemeId?: string | null
onSwitchTheme?: (themeId: string) => void
onCreateTheme?: () => void
onOpenTheme?: (themeId: string) => void
}
const PLURAL_OVERRIDES: Record<string, string> = {
@@ -132,22 +135,36 @@ export function buildThemeCommands(
activeThemeId: string | null | undefined,
onSwitchTheme: ((themeId: string) => void) | undefined,
onCreateTheme: (() => void) | undefined,
onOpenTheme: ((themeId: string) => void) | undefined,
): CommandAction[] {
const switchCmds = (themes ?? []).map(t => ({
id: `switch-theme-${t.id}`,
label: `Switch to ${t.name} Theme`,
group: 'Appearance' as CommandGroup,
keywords: ['theme', 'appearance', 'color', t.name.toLowerCase()],
enabled: t.id !== activeThemeId,
execute: () => onSwitchTheme?.(t.id),
}))
const cmds: CommandAction[] = []
for (const t of (themes ?? [])) {
cmds.push({
id: `switch-theme-${t.id}`,
label: `Switch to ${t.name} Theme`,
group: 'Appearance' as CommandGroup,
keywords: ['theme', 'appearance', 'color', t.name.toLowerCase()],
enabled: t.id !== activeThemeId,
execute: () => onSwitchTheme?.(t.id),
})
if (onOpenTheme) {
cmds.push({
id: `open-theme-${t.id}`,
label: `Edit ${t.name} Theme`,
group: 'Appearance' as CommandGroup,
keywords: ['theme', 'edit', 'open', 'appearance', t.name.toLowerCase()],
enabled: true,
execute: () => onOpenTheme(t.id),
})
}
}
if (onCreateTheme) {
switchCmds.push({
cmds.push({
id: 'new-theme', label: 'New Theme', group: 'Appearance' as CommandGroup,
keywords: ['theme', 'create', 'appearance'], enabled: true, execute: onCreateTheme,
})
}
return switchCmds
return cmds
}
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
@@ -159,7 +176,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme,
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onCheckForUpdates, isUpdating,
} = config
const hasActiveNote = activeTabPath !== null
@@ -209,11 +227,12 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
...buildViewCommands(hasActiveNote, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
// Appearance
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme),
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme),
// Settings
{ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: !isUpdating, execute: () => onCheckForUpdates?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -225,9 +244,10 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates, isUpdating,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenVault,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
])
}

View File

@@ -110,6 +110,9 @@ describe('useEditorFocus', () => {
it('selects H1 text after timeout when editor not yet mounted', () => {
vi.useFakeTimers()
// Mock rAF synchronously so the deferred selectFirstHeading call inside doFocus
// runs immediately when requestAnimationFrame is invoked, keeping the test simple.
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const tiptap = makeTiptapMock(true)
const { editor } = setup(false, tiptap)
@@ -122,5 +125,37 @@ describe('useEditorFocus', () => {
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
vi.useRealTimers()
})
it('selection happens in second rAF (not first), allowing content swap to complete', () => {
// Verify the double-rAF contract: focus in rAF1, selection deferred to rAF2.
// This ensures the new note's blocks are applied (via queueMicrotask between frames)
// before selectFirstHeading runs.
const callbacks: FrameRequestCallback[] = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
callbacks.push(cb)
return callbacks.length
})
const tiptap = makeTiptapMock(true)
const { editor } = setup(true, tiptap)
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
// rAF 1 is scheduled (doFocus)
expect(callbacks.length).toBe(1)
callbacks[0](0)
// After rAF 1: editor focused, but selection NOT yet triggered
expect(editor.focus).toHaveBeenCalled()
expect(tiptap.chain).not.toHaveBeenCalled()
// rAF 2 is now scheduled (selectFirstHeading)
expect(callbacks.length).toBe(2)
callbacks[1](0)
// After rAF 2: heading is selected
expect(tiptap.chain).toHaveBeenCalled()
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
expect(tiptap._chainResult.run).toHaveBeenCalled()
})
})
})

View File

@@ -48,8 +48,20 @@ export function useEditorFocus(
const selectTitle = detail?.selectTitle ?? false
const doFocus = () => {
editor.focus()
if (selectTitle) selectFirstHeading(editor)
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
if (!selectTitle) {
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
return
}
// Defer selection to the next animation frame so the new note's content
// (applied via queueMicrotask inside a React effect triggered by the tab
// change) is in the document before we try to select the heading.
// Between two rAF callbacks, all pending macrotasks — including React's
// MessageChannel re-render and the subsequent queueMicrotask content swap
// — complete, so the heading block is guaranteed to exist by rAF 2.
requestAnimationFrame(() => {
selectFirstHeading(editor)
if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`)
})
}
if (editorMountedRef.current) {
requestAnimationFrame(doFocus)

View File

@@ -238,4 +238,54 @@ describe('useEntryActions', () => {
expect(updateEntry).toHaveBeenCalledTimes(1)
})
})
describe('handleRenameSection', () => {
it('writes sidebar label frontmatter and updates entry in memory', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleRenameSection('Recipe', 'Recipes')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' })
})
it('trims whitespace before saving', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleRenameSection('Recipe', ' Dishes ')
})
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Dishes')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Dishes' })
})
it('deletes sidebar label when label is empty', async () => {
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: 'Dishes' })
const { result } = setup([typeEntry])
await act(async () => {
await result.current.handleRenameSection('Recipe', '')
})
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label')
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: null })
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
})
it('does nothing when type entry not found', async () => {
const { result } = setup([])
await act(async () => {
await result.current.handleRenameSection('NonExistent', 'Label')
})
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
expect(updateEntry).not.toHaveBeenCalled()
})
})
})

View File

@@ -68,5 +68,17 @@ export function useEntryActions({
updateEntry(typeEntry.path, { template: template || null })
}, [entries, handleUpdateFrontmatter, updateEntry])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate }
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
const typeEntry = findTypeEntry(entries, typeName)
if (!typeEntry) return
const trimmed = label.trim()
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
if (trimmed) {
await handleUpdateFrontmatter(typeEntry.path, 'sidebar label', trimmed)
} else {
await handleDeleteProperty(typeEntry.path, 'sidebar label')
}
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection }
}

View File

@@ -35,7 +35,7 @@ describe('useOnboarding', () => {
it('transitions to ready when vault exists', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return true
return null
})
@@ -50,7 +50,7 @@ describe('useOnboarding', () => {
it('shows welcome screen when vault does not exist', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -60,14 +60,14 @@ describe('useOnboarding', () => {
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Laputa' })
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Getting Started' })
})
it('shows vault-missing when previously dismissed and vault gone', async () => {
localStorage.setItem('laputa_welcome_dismissed', '1')
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -80,15 +80,15 @@ describe('useOnboarding', () => {
expect(result.current.state).toEqual({
status: 'vault-missing',
vaultPath: '/vault/deleted',
defaultPath: '/mock/Documents/Laputa',
defaultPath: '/mock/Documents/Getting Started',
})
})
it('handleCreateVault creates vault and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Laputa'
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Getting Started'
return null
})
@@ -102,13 +102,13 @@ describe('useOnboarding', () => {
await result.current.handleCreateVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Laputa' })
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
})
it('handleCreateVault sets error on failure', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_getting_started_vault') throw 'Permission denied'
return null
@@ -130,7 +130,7 @@ describe('useOnboarding', () => {
it('handleOpenFolder opens folder picker and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -152,7 +152,7 @@ describe('useOnboarding', () => {
it('handleOpenFolder does nothing when picker is cancelled', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
@@ -173,7 +173,7 @@ describe('useOnboarding', () => {
it('handleDismiss marks dismissed and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Laputa'
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})

View File

@@ -1,356 +1,348 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import type { ThemeFile, VaultSettings } from '../types'
import type { VaultEntry } from '../types'
const mockThemes: ThemeFile[] = [
{
id: 'default', name: 'Default', description: 'Clean default theme',
colors: { background: '#FFFFFF', foreground: '#1A1A2E', primary: '#6366F1', border: '#E2E8F0', 'sidebar-background': '#F8FAFC' },
typography: { 'font-family': 'Inter, sans-serif' },
spacing: { 'sidebar-width': '240px' },
},
{
id: 'dark', name: 'Dark', description: 'Dark theme',
colors: { background: '#0F0F23', foreground: '#E2E8F0', primary: '#818CF8', border: '#1E293B' },
typography: { 'font-family': 'Inter, sans-serif' },
spacing: {},
},
]
const THEME_PATH_DEFAULT = '/vault/theme/default.md'
const THEME_PATH_DARK = '/vault/theme/dark.md'
const mockSettings: VaultSettings = { theme: 'default' }
const DEFAULT_THEME_CONTENT = `---
Is A: Theme
Description: Light theme
background: "#FFFFFF"
foreground: "#37352F"
primary: "#155DFF"
sidebar: "#F7F6F3"
text-primary: "#37352F"
---
const mockInvokeFn = vi.fn(async (cmd: string) => {
if (cmd === 'list_themes') return mockThemes
if (cmd === 'get_vault_settings') return mockSettings
# Default Theme
`
const DARK_THEME_CONTENT = `---
Is A: Theme
Description: Dark theme
background: "#0f0f1a"
foreground: "#e0e0e0"
primary: "#155DFF"
sidebar: "#1a1a2e"
text-primary: "#e0e0e0"
---
# Dark Theme
`
function makeThemeEntry(path: string, title: string): VaultEntry {
return {
path,
filename: path.split('/').pop()!,
title,
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: null,
createdAt: null,
fileSize: 0,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
}
}
const defaultEntry = makeThemeEntry(THEME_PATH_DEFAULT, 'Default Theme')
const darkEntry = makeThemeEntry(THEME_PATH_DARK, 'Dark Theme')
const mockInvokeFn = vi.fn(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT }
if (cmd === 'get_note_content') {
const path = args?.path as string | undefined
if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT
if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT
return ''
}
if (cmd === 'set_active_theme') return null
if (cmd === 'create_theme') return 'new-theme-id'
if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md'
return null
})
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
// Must import after mocks
const { useThemeManager, isColorDark } = await import('./useThemeManager')
const { useThemeManager, extractCssVars } = await import('./useThemeManager')
describe('isColorDark', () => {
it('identifies dark colors', () => {
expect(isColorDark('#000000')).toBe(true)
expect(isColorDark('#0F0F23')).toBe(true)
expect(isColorDark('#1a1a2e')).toBe(true)
expect(isColorDark('#0f0f1a')).toBe(true)
describe('extractCssVars', () => {
it('extracts color variables from frontmatter', () => {
const vars = extractCssVars(DEFAULT_THEME_CONTENT)
expect(vars['--background']).toBe('#FFFFFF')
expect(vars['--foreground']).toBe('#37352F')
expect(vars['--primary']).toBe('#155DFF')
})
it('identifies light colors', () => {
expect(isColorDark('#FFFFFF')).toBe(false)
expect(isColorDark('#F7F6F3')).toBe(false)
expect(isColorDark('#E0E0E0')).toBe(false)
it('excludes metadata keys', () => {
const vars = extractCssVars(DEFAULT_THEME_CONTENT)
expect('--Is A' in vars).toBe(false)
expect('--Description' in vars).toBe(false)
})
})
describe('useThemeManager', () => {
const entries = [defaultEntry, darkEntry]
const allContent: Record<string, string> = {}
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'list_themes') return mockThemes
if (cmd === 'get_vault_settings') return mockSettings
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DEFAULT }
if (cmd === 'get_note_content') {
const path = args?.path as string | undefined
if (path === THEME_PATH_DEFAULT) return DEFAULT_THEME_CONTENT
if (path === THEME_PATH_DARK) return DARK_THEME_CONTENT
return ''
}
if (cmd === 'set_active_theme') return null
if (cmd === 'create_theme') return 'new-theme-id'
if (cmd === 'create_vault_theme') return '/vault/theme/untitled.md'
return null
})
// Clear any theme CSS properties from previous tests
const root = document.documentElement
root.style.cssText = ''
document.documentElement.style.cssText = ''
})
it('loads themes and active theme on mount', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
it('builds themes list from vault entries with isA === Theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
expect(result.current.activeThemeId).toBe('default')
expect(result.current.activeTheme?.name).toBe('Default')
expect(result.current.themes[0].name).toBe('Default Theme')
expect(result.current.themes[0].id).toBe(THEME_PATH_DEFAULT)
})
it('loads active theme from vault settings on mount', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
expect(result.current.activeTheme?.name).toBe('Default Theme')
})
it('applies CSS vars from theme note content', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--foreground')).toBe('#37352F')
expect(root.style.getPropertyValue('--primary')).toBe('#155DFF')
})
it('returns empty state when vaultPath is null', async () => {
const { result } = renderHook(() => useThemeManager(null))
// Give it time to settle — should remain empty
const { result } = renderHook(() =>
useThemeManager(null, entries, allContent)
)
await new Promise(r => setTimeout(r, 50))
expect(result.current.themes).toHaveLength(0)
expect(result.current.activeThemeId).toBeNull()
expect(result.current.activeTheme).toBeNull()
expect(mockInvokeFn).not.toHaveBeenCalled()
})
it('applies CSS custom properties for active theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
it('excludes trashed entries from themes list', async () => {
const trashedEntry = { ...darkEntry, trashed: true }
const { result } = renderHook(() =>
useThemeManager('/vault', [defaultEntry, trashedEntry], allContent)
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
expect(result.current.themes).toHaveLength(1)
})
const root = document.documentElement
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--foreground')).toBe('#1A1A2E')
expect(root.style.getPropertyValue('--primary')).toBe('#6366F1')
expect(root.style.getPropertyValue('--theme-background')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--theme-font-family')).toBe('Inter, sans-serif')
expect(root.style.getPropertyValue('--theme-sidebar-width')).toBe('240px')
})
it('maps sidebar-background to --sidebar', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#F8FAFC')
expect(result.current.themes[0].name).toBe('Default Theme')
})
it('switchTheme calls set_active_theme and updates activeThemeId', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
await act(async () => {
await result.current.switchTheme(THEME_PATH_DARK)
})
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', {
vaultPath: '/vault', themeId: THEME_PATH_DARK,
})
expect(result.current.activeThemeId).toBe(THEME_PATH_DARK)
})
it('clears old CSS vars and applies new theme on switch', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
})
await act(async () => {
await result.current.switchTheme('dark')
await result.current.switchTheme(THEME_PATH_DARK)
})
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', { vaultPath: '/vault', themeId: 'dark' })
expect(result.current.activeThemeId).toBe('dark')
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#0f0f1a')
})
})
it('clears old theme CSS and applies new theme on switch', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme?.id).toBe('default')
})
const root = document.documentElement
expect(root.style.getPropertyValue('--background')).toBe('#FFFFFF')
it('createTheme calls create_vault_theme and switches to new theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
let newPath = ''
await act(async () => {
await result.current.switchTheme('dark')
newPath = await result.current.createTheme('My Theme')
})
await waitFor(() => {
expect(root.style.getPropertyValue('--background')).toBe('#0F0F23')
expect(newPath).toBe('/vault/theme/untitled.md')
expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', {
vaultPath: '/vault', name: 'My Theme',
})
expect(root.style.getPropertyValue('--foreground')).toBe('#E2E8F0')
expect(result.current.activeThemeId).toBe('/vault/theme/untitled.md')
})
it('createTheme calls create_theme and reloads themes', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
let newId = ''
await act(async () => {
newId = await result.current.createTheme('default')
})
expect(newId).toBe('new-theme-id')
expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vaultPath: '/vault', sourceId: 'default' })
// Should reload after creation
const listCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes')
expect(listCalls.length).toBeGreaterThanOrEqual(2)
})
it('createTheme passes null source_id when no sourceId provided', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
it('createTheme passes null name when none provided', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
await act(async () => {
await result.current.createTheme()
})
expect(mockInvokeFn).toHaveBeenCalledWith('create_theme', { vaultPath: '/vault', sourceId: null })
expect(mockInvokeFn).toHaveBeenCalledWith('create_vault_theme', {
vaultPath: '/vault', name: null,
})
})
it('falls back when active theme is trashed', async () => {
const { result, rerender } = renderHook(
({ ents }) => useThemeManager('/vault', ents, allContent),
{ initialProps: { ents: entries } },
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
const trashedDefault = { ...defaultEntry, trashed: true }
rerender({ ents: [trashedDefault, darkEntry] })
await waitFor(() => {
expect(result.current.activeThemeId).toBeNull()
})
// CSS vars are cleared from tracked applied vars — DOM state depends on prior apply
})
it('handles load failure gracefully', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockInvokeFn.mockRejectedValue(new Error('disk error'))
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(warnSpy).toHaveBeenCalledWith('Failed to load themes:', expect.any(Error))
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await new Promise(r => setTimeout(r, 50))
expect(result.current.themes).toHaveLength(0)
expect(result.current.activeThemeId).toBeNull()
warnSpy.mockRestore()
})
it('handles switchTheme failure gracefully', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
mockInvokeFn.mockRejectedValueOnce(new Error('permission denied'))
await act(async () => {
await result.current.switchTheme('dark')
await result.current.switchTheme(THEME_PATH_DARK)
})
// Should not have changed the active theme
expect(result.current.activeThemeId).toBe('default')
expect(errorSpy).toHaveBeenCalledWith('Failed to switch theme:', expect.any(Error))
errorSpy.mockRestore()
})
it('handles createTheme failure gracefully', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
mockInvokeFn.mockRejectedValueOnce(new Error('write error'))
let newId = ''
await act(async () => {
newId = await result.current.createTheme('default')
})
expect(newId).toBe('')
expect(errorSpy).toHaveBeenCalledWith('Failed to create theme:', expect.any(Error))
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
errorSpy.mockRestore()
})
it('switchTheme is a no-op when vaultPath is null', async () => {
const { result } = renderHook(() => useThemeManager(null))
const { result } = renderHook(() =>
useThemeManager(null, entries, allContent)
)
await act(async () => {
await result.current.switchTheme('dark')
await result.current.switchTheme(THEME_PATH_DARK)
})
expect(mockInvokeFn).not.toHaveBeenCalledWith('set_active_theme', expect.anything())
})
it('createTheme returns empty string when vaultPath is null', async () => {
const { result } = renderHook(() => useThemeManager(null))
let newId = ''
const { result } = renderHook(() =>
useThemeManager(null, entries, allContent)
)
let newPath = ''
await act(async () => {
newId = await result.current.createTheme()
newPath = await result.current.createTheme()
})
expect(newId).toBe('')
expect(newPath).toBe('')
})
it('isDark is false for light theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme?.id).toBe('default')
})
expect(result.current.isDark).toBe(false)
})
it('isDark is true for dark theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme('dark')
})
expect(result.current.isDark).toBe(true)
})
it('isDark is false when no active theme', async () => {
const { result } = renderHook(() => useThemeManager(null))
expect(result.current.isDark).toBe(false)
})
it('derives app-specific CSS variables from theme colors', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
it('re-applies theme when active content changes in allContent', async () => {
const { result, rerender } = renderHook(
({ content }) => useThemeManager('/vault', entries, content),
{ initialProps: { content: allContent } },
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
expect(root.style.getPropertyValue('--text-primary')).toBe('#1A1A2E')
expect(root.style.getPropertyValue('--text-heading')).toBe('#1A1A2E')
expect(root.style.getPropertyValue('--border-primary')).toBe('#E2E8F0')
const newContent = {
[THEME_PATH_DEFAULT]: `---\nIs A: Theme\nbackground: "#FF0000"\n---\n# Default Theme\n`,
}
rerender({ content: newContent })
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FF0000')
})
})
it('sets color-scheme and data-theme-mode for dark theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
it('reloadThemes re-reads vault settings', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
await act(async () => {
await result.current.switchTheme('dark')
})
const root = document.documentElement
await waitFor(() => {
expect(root.style.getPropertyValue('color-scheme')).toBe('dark')
})
expect(root.dataset.themeMode).toBe('dark')
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
expect(root.style.getPropertyValue('--text-primary')).toBe('#E2E8F0')
})
it('sets color-scheme to light for light theme', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
expect(root.dataset.themeMode).toBe('light')
})
it('updates derived variables when switching between themes', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
await act(async () => {
await result.current.switchTheme('dark')
})
const root = document.documentElement
await waitFor(() => {
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
})
await act(async () => {
await result.current.switchTheme('default')
})
await waitFor(() => {
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
})
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
expect(root.dataset.themeMode).toBe('light')
})
it('reloadThemes re-fetches theme list', async () => {
const { result } = renderHook(() => useThemeManager('/vault'))
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
const initialListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
const initialCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length
await act(async () => {
await result.current.reloadThemes()
})
const afterListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
expect(afterListCalls).toBe(initialListCalls + 1)
const afterCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length
expect(afterCalls).toBe(initialCalls + 1)
})
})

View File

@@ -1,157 +1,76 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { ThemeFile, VaultSettings } from '../types'
import { parseFrontmatter } from '../utils/frontmatter'
import type { ThemeFile, VaultEntry, VaultSettings } from '../types'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
// --- Color utilities for theme variable derivation ---
/** Frontmatter keys that are metadata — not CSS custom properties. */
const NON_THEME_KEYS = new Set([
'Is A', 'type', 'is_a', 'is a',
'Name', 'name', 'title', 'Title',
'Description', 'description',
'Archived', 'archived',
'Trashed', 'trashed',
'Trashed at', 'trashed at', 'trashed_at',
'Created at', 'created at', 'created_at',
'Created time', 'created_time',
'Owner', 'owner',
'Status', 'status',
'Cadence', 'cadence',
'aliases',
'Belongs to', 'belongs_to', 'belongs to',
'Related to', 'related_to', 'related to',
])
function parseHex(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
}
function toHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(c => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')
}
/** Blend two hex colors. ratio=0 → color1, ratio=1 → color2. */
function mixColors(hex1: string, hex2: string, ratio: number): string {
const [r1, g1, b1] = parseHex(hex1)
const [r2, g2, b2] = parseHex(hex2)
return toHex(r1 + (r2 - r1) * ratio, g1 + (g2 - g1) * ratio, b1 + (b2 - b1) * ratio)
}
/** Check if a hex color is perceptually dark (luminance < 0.5). */
export function isColorDark(hex: string): boolean {
const [r, g, b] = parseHex(hex)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
}
// Variables derived from theme core colors (not present in theme.colors directly)
const DERIVED_VAR_NAMES = [
'bg-primary', 'bg-sidebar', 'bg-card', 'bg-hover', 'bg-hover-subtle', 'bg-selected',
'bg-input', 'bg-button', 'bg-dialog',
'text-primary', 'text-heading', 'text-secondary', 'text-tertiary', 'text-muted', 'text-faint',
'border-primary', 'border-subtle', 'border-input', 'border-dialog',
'link-color', 'link-hover',
// shadcn variables that may not be in the theme
'card', 'card-foreground', 'popover', 'popover-foreground',
'secondary', 'secondary-foreground', 'muted-foreground',
'accent', 'accent-foreground', 'input', 'ring',
'sidebar-foreground', 'sidebar-primary', 'sidebar-primary-foreground',
'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',
]
/** Derive app-specific and missing shadcn CSS variables from core theme colors. */
function deriveThemeVariables(root: HTMLElement, colors: Record<string, string>): void {
const bg = colors.background
const fg = colors.foreground
if (!bg || !fg) return
const isDark = isColorDark(bg)
root.style.setProperty('color-scheme', isDark ? 'dark' : 'light')
root.dataset.themeMode = isDark ? 'dark' : 'light'
const primary = colors.primary ?? (isDark ? '#5C9CFF' : '#155DFF')
const border = colors.border ?? mixColors(bg, fg, isDark ? 0.15 : 0.08)
const muted = colors.muted ?? mixColors(bg, fg, isDark ? 0.08 : 0.05)
const sidebarBg = colors['sidebar-background'] ?? mixColors(bg, fg, 0.04)
// App-specific variables
root.style.setProperty('--bg-primary', bg)
root.style.setProperty('--bg-sidebar', sidebarBg)
root.style.setProperty('--bg-card', mixColors(bg, fg, 0.03))
root.style.setProperty('--bg-hover', mixColors(bg, fg, 0.1))
root.style.setProperty('--bg-hover-subtle', muted)
root.style.setProperty('--bg-selected', `${primary}25`)
root.style.setProperty('--bg-input', bg)
root.style.setProperty('--bg-button', mixColors(bg, fg, 0.1))
root.style.setProperty('--bg-dialog', mixColors(bg, fg, 0.02))
root.style.setProperty('--text-primary', fg)
root.style.setProperty('--text-heading', fg)
root.style.setProperty('--text-secondary', mixColors(fg, bg, 0.25))
root.style.setProperty('--text-tertiary', mixColors(fg, bg, 0.35))
root.style.setProperty('--text-muted', mixColors(fg, bg, 0.5))
root.style.setProperty('--text-faint', mixColors(fg, bg, 0.6))
root.style.setProperty('--border-primary', border)
root.style.setProperty('--border-subtle', border)
root.style.setProperty('--border-input', border)
root.style.setProperty('--border-dialog', border)
root.style.setProperty('--link-color', primary)
root.style.setProperty('--link-hover', mixColors(primary, fg, 0.2))
// Shadcn variables — only set if not already provided by the theme
const setIfMissing = (name: string, value: string) => {
if (!(name in colors)) root.style.setProperty(`--${name}`, value)
/** Extract CSS custom properties from a theme note's frontmatter content. */
export function extractCssVars(content: string): Record<string, string> {
const fm = parseFrontmatter(content)
const vars: Record<string, string> = {}
for (const [key, value] of Object.entries(fm)) {
if (NON_THEME_KEYS.has(key)) continue
if (typeof value === 'string' && value) {
vars[`--${key}`] = value
} else if (typeof value === 'number') {
vars[`--${key}`] = String(value)
}
}
setIfMissing('card', mixColors(bg, fg, 0.03))
setIfMissing('card-foreground', fg)
setIfMissing('popover', mixColors(bg, fg, 0.04))
setIfMissing('popover-foreground', fg)
setIfMissing('secondary', mixColors(bg, fg, 0.08))
setIfMissing('secondary-foreground', fg)
setIfMissing('muted-foreground', mixColors(fg, bg, 0.3))
setIfMissing('accent', mixColors(bg, fg, 0.08))
setIfMissing('accent-foreground', fg)
setIfMissing('input', border)
setIfMissing('ring', primary)
setIfMissing('sidebar-foreground', fg)
setIfMissing('sidebar-accent', mixColors(sidebarBg, fg, 0.1))
setIfMissing('sidebar-accent-foreground', fg)
setIfMissing('sidebar-border', border)
setIfMissing('sidebar-primary', primary)
setIfMissing('sidebar-primary-foreground', '#FFFFFF')
setIfMissing('sidebar-ring', primary)
return vars
}
function clearDerivedVariables(root: HTMLElement): void {
for (const name of DERIVED_VAR_NAMES) {
root.style.removeProperty(`--${name}`)
}
root.style.removeProperty('color-scheme')
delete root.dataset.themeMode
}
/** Map theme colors/typography/spacing to CSS custom properties on :root. */
function applyThemeToDom(theme: ThemeFile): void {
function applyVarsToDom(vars: Record<string, string>): void {
const root = document.documentElement
for (const [key, value] of Object.entries(theme.colors)) {
root.style.setProperty(`--theme-${key}`, value)
root.style.setProperty(`--${key}`, value)
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value)
}
for (const [key, value] of Object.entries(theme.typography)) {
root.style.setProperty(`--theme-${key}`, value)
}
for (const [key, value] of Object.entries(theme.spacing)) {
root.style.setProperty(`--theme-${key}`, value)
}
if (theme.colors['sidebar-background']) {
root.style.setProperty('--sidebar', theme.colors['sidebar-background'])
}
deriveThemeVariables(root, theme.colors)
}
function clearThemeFromDom(theme: ThemeFile): void {
function clearVarsFromDom(vars: Record<string, string>): void {
const root = document.documentElement
for (const key of Object.keys(theme.colors)) {
root.style.removeProperty(`--theme-${key}`)
root.style.removeProperty(`--${key}`)
for (const key of Object.keys(vars)) {
root.style.removeProperty(key)
}
for (const key of Object.keys(theme.typography)) {
root.style.removeProperty(`--theme-${key}`)
}
/** Build a ThemeFile descriptor from a vault entry (metadata only). */
function entryToThemeFile(entry: VaultEntry): ThemeFile {
return {
id: entry.path,
name: entry.title,
description: '',
path: entry.path,
colors: {},
typography: {},
spacing: {},
}
for (const key of Object.keys(theme.spacing)) {
root.style.removeProperty(`--theme-${key}`)
}
root.style.removeProperty('--sidebar')
clearDerivedVariables(root)
}
/** True when a theme entry should no longer be applied (trashed or archived). */
function isEntryRemoved(entry: VaultEntry): boolean {
return entry.trashed || entry.archived
}
export interface ThemeManager {
@@ -160,81 +79,125 @@ export interface ThemeManager {
activeTheme: ThemeFile | null
isDark: boolean
switchTheme: (themeId: string) => Promise<void>
createTheme: (sourceId?: string) => Promise<string>
createTheme: (name?: string) => Promise<string>
reloadThemes: () => Promise<void>
}
/** Sync CSS custom properties: clear old theme, apply new one. */
function syncThemeDom(
prevRef: React.MutableRefObject<ThemeFile | null>,
theme: ThemeFile | null,
): void {
if (prevRef.current) clearThemeFromDom(prevRef.current)
if (theme) {
applyThemeToDom(theme)
prevRef.current = theme
} else {
prevRef.current = null
}
}
export function useThemeManager(vaultPath: string | null): ThemeManager {
const [themes, setThemes] = useState<ThemeFile[]>([])
/** Manages loading and persisting the active theme path from vault settings. */
function useThemeSetting(vaultPath: string | null) {
const [activeThemeId, setActiveThemeId] = useState<string | null>(null)
const prevThemeRef = useRef<ThemeFile | null>(null)
const activeTheme = themes.find(t => t.id === activeThemeId) ?? null
const isDark = activeTheme?.colors.background ? isColorDark(activeTheme.colors.background) : false
const loadThemes = useCallback(async () => {
const load = useCallback(async () => {
if (!vaultPath) return
try {
const [themeList, settings] = await Promise.all([
tauriCall<ThemeFile[]>('list_themes', { vaultPath }),
tauriCall<VaultSettings>('get_vault_settings', { vaultPath }),
])
setThemes(themeList)
setActiveThemeId(settings.theme)
} catch (err) {
console.warn('Failed to load themes:', err)
}
const s = await tauriCall<VaultSettings>('get_vault_settings', { vaultPath })
setActiveThemeId(s.theme)
} catch { /* no settings file — fine, no active theme */ }
}, [vaultPath])
useEffect(() => { loadThemes() }, [loadThemes]) // eslint-disable-line react-hooks/set-state-in-effect -- trigger initial load
useEffect(() => { syncThemeDom(prevThemeRef, activeTheme) }, [activeTheme])
// eslint-disable-next-line react-hooks/set-state-in-effect -- async fn; setState runs after await
useEffect(() => { load() }, [load])
// Reload themes when window regains focus (live reload for external edits)
useEffect(() => {
const onFocus = () => { loadThemes() }
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [loadThemes])
window.addEventListener('focus', load)
return () => window.removeEventListener('focus', load)
}, [load])
return { activeThemeId, setActiveThemeId, reload: load }
}
/** Applies CSS custom properties to the document root from the active theme. */
function useThemeApplier(
activeThemeId: string | null,
cachedContent: string | undefined,
) {
const appliedVarsRef = useRef<Record<string, string>>({})
const apply = useCallback((content: string) => {
const newVars = extractCssVars(content)
clearVarsFromDom(appliedVarsRef.current)
applyVarsToDom(newVars)
appliedVarsRef.current = newVars
}, [])
const clear = useCallback(() => {
clearVarsFromDom(appliedVarsRef.current)
appliedVarsRef.current = {}
}, [])
// Apply theme when activeThemeId or cached content changes.
// Also serves as live-preview: re-applies when the user saves the theme note.
useEffect(() => {
if (!activeThemeId) { clear(); return }
if (cachedContent) { apply(cachedContent); return }
tauriCall<string>('get_note_content', { path: activeThemeId })
.then(apply)
.catch(clear)
}, [activeThemeId, cachedContent, apply, clear])
return { clear }
}
export function useThemeManager(
vaultPath: string | null,
entries: VaultEntry[],
allContent: Record<string, string>,
): ThemeManager {
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined
const { clear: clearTheme } = useThemeApplier(activeThemeId, cachedThemeContent)
const themes = useMemo(
() => entries.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived).map(entryToThemeFile),
[entries],
)
const activeTheme = useMemo(
() => themes.find(t => t.id === activeThemeId) ?? null,
[themes, activeThemeId],
)
// If active theme is trashed or archived: clear CSS vars and fall back to no theme
useEffect(() => {
if (!activeThemeId) return
const entry = entries.find(e => e.path === activeThemeId)
if (!entry || !isEntryRemoved(entry)) return
clearTheme()
setActiveThemeId(null)
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
}, [entries, activeThemeId, clearTheme, vaultPath, setActiveThemeId])
const switchTheme = useCallback(async (themeId: string) => {
if (!vaultPath) return
try {
await tauriCall<null>('set_active_theme', { vaultPath, themeId })
setActiveThemeId(themeId)
} catch (err) {
console.error('Failed to switch theme:', err)
}
}, [vaultPath])
} catch (err) { console.error('Failed to switch theme:', err) }
}, [vaultPath, setActiveThemeId])
const createTheme = useCallback(async (sourceId?: string) => {
const createTheme = useCallback(async (name?: string) => {
if (!vaultPath) return ''
try {
const newId = await tauriCall<string>('create_theme', {
vaultPath,
sourceId: sourceId ?? null,
})
await loadThemes()
await switchTheme(newId)
return newId
} catch (err) {
console.error('Failed to create theme:', err)
return ''
}
}, [vaultPath, loadThemes, switchTheme])
const path = await tauriCall<string>('create_vault_theme', { vaultPath, name: name ?? null })
await tauriCall<null>('set_active_theme', { vaultPath, themeId: path })
setActiveThemeId(path)
return path
} catch (err) { console.error('Failed to create theme:', err); return '' }
}, [vaultPath, setActiveThemeId])
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes: loadThemes }
const reloadThemes = useCallback(async () => { await reload() }, [reload])
// Determine if the active theme is dark by checking --background CSS variable
const isDark = useMemo(() => {
if (!activeThemeId || !cachedThemeContent) return false
const vars = extractCssVars(cachedThemeContent)
const bg = vars['--background'] ?? ''
if (!bg.startsWith('#') || bg.length < 7) return false
const r = parseInt(bg.slice(1, 3), 16)
const g = parseInt(bg.slice(3, 5), 16)
const b = parseInt(bg.slice(5, 7), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
}, [activeThemeId, cachedThemeContent])
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes }
}

View File

@@ -199,4 +199,73 @@ describe('useUpdater', () => {
expect(result.current.status).toEqual({ state: 'ready', version: '1.2.0' })
expect(mockDownload).toHaveBeenCalled()
})
describe('checkForUpdates (manual)', () => {
it('returns up-to-date when no update is available', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockResolvedValue(null)
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('up-to-date')
expect(result.current.status).toEqual({ state: 'idle' })
})
it('returns available and sets status when update exists', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockResolvedValue({
version: '3.0.0',
body: 'Major release',
downloadAndInstall: vi.fn(),
})
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('available')
expect(result.current.status).toEqual({
state: 'available',
version: '3.0.0',
notes: 'Major release',
})
})
it('returns error on network failure', async () => {
vi.mocked(isTauri).mockReturnValue(true)
mockCheck.mockRejectedValue(new Error('No internet'))
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('error')
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
})
it('returns up-to-date when not in Tauri', async () => {
vi.mocked(isTauri).mockReturnValue(false)
const { result } = renderHook(() => useUpdater())
let checkResult: string | undefined
await act(async () => {
checkResult = await result.current.actions.checkForUpdates()
})
expect(checkResult).toBe('up-to-date')
expect(mockCheck).not.toHaveBeenCalled()
})
})
})

View File

@@ -11,7 +11,10 @@ export type UpdateStatus =
| { state: 'ready'; version: string }
| { state: 'error' }
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
export interface UpdateActions {
checkForUpdates: () => Promise<UpdateCheckResult>
startDownload: () => void
openReleaseNotes: () => void
dismiss: () => void
@@ -21,31 +24,32 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' })
const updateRef = useRef<unknown>(null)
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
if (!isTauri()) return 'up-to-date'
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
if (!update) return 'up-to-date'
updateRef.current = update
setStatus({
state: 'available',
version: update.version,
notes: update.body ?? undefined,
})
return 'available'
} catch {
console.warn('[updater] Failed to check for updates')
return 'error'
}
}, [])
useEffect(() => {
if (!isTauri()) return
const checkForUpdates = async () => {
try {
const { check } = await import('@tauri-apps/plugin-updater')
const update = await check()
if (!update) return // up to date
updateRef.current = update
setStatus({
state: 'available',
version: update.version,
notes: update.body ?? undefined,
})
} catch {
// Network error or 404 — fail silently
console.warn('[updater] Failed to check for updates')
}
}
// Delay so the app can render first
const timer = setTimeout(checkForUpdates, 3000)
const timer = setTimeout(() => { checkForUpdates() }, 3000)
return () => clearTimeout(timer)
}, [])
}, [checkForUpdates])
const startDownload = useCallback(async () => {
const update = updateRef.current as {
@@ -88,7 +92,7 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
setStatus({ state: 'idle' })
}, [])
return { status, actions: { startDownload, openReleaseNotes, dismiss } }
return { status, actions: { checkForUpdates, startDownload, openReleaseNotes, dismiss } }
}
/**

View File

@@ -97,13 +97,14 @@ export function useVaultLoader(vaultPath: string) {
const [entries, setEntries] = useState<VaultEntry[]>([])
const [allContent, setAllContent] = useState<Record<string, string>>({})
const [modifiedFiles, setModifiedFiles] = useState<ModifiedFile[]>([])
const [modifiedFilesError, setModifiedFilesError] = useState<string | null>(null)
const tracker = useNewNoteTracker()
const pendingSave = usePendingSaveTracker()
const unsaved = useUnsavedTracker()
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale data then load new vault
setEntries([]); setAllContent({}); setModifiedFiles([]); tracker.clear(); unsaved.clearAll()
setEntries([]); setAllContent({}); setModifiedFiles([]); setModifiedFilesError(null); tracker.clear(); unsaved.clearAll()
loadVaultData(vaultPath)
.then(({ entries: e, allContent: c }) => { setEntries(e); setAllContent(c) })
.catch((err) => console.warn('Vault scan failed:', err))
@@ -111,9 +112,12 @@ export function useVaultLoader(vaultPath: string) {
const loadModifiedFiles = useCallback(async () => {
try {
setModifiedFilesError(null)
setModifiedFiles(await tauriCall<ModifiedFile[]>('get_modified_files', { vaultPath }, {}))
} catch (err) {
const message = typeof err === 'string' ? err : 'Failed to load changes'
console.warn('Failed to load modified files:', err)
setModifiedFilesError(message)
setModifiedFiles([])
}
}, [vaultPath])
@@ -174,7 +178,7 @@ export function useVaultLoader(vaultPath: string) {
)
return {
entries, allContent, modifiedFiles,
entries, allContent, modifiedFiles, modifiedFilesError,
addEntry, updateEntry, removeEntry, replaceEntry, updateContent,
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
getNoteStatus, commitAndPush, reloadVault,

View File

@@ -1,16 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { isTauri } from '../mock-tauri'
import { pickFolder } from '../utils/vault-dialog'
import type { VaultOption } from '../components/StatusBar'
export const DEFAULT_VAULTS: VaultOption[] = isTauri()
? [
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
{ label: 'Laputa', path: '/Users/luca/Laputa' },
]
: [
{ label: 'Demo v2', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
]
export const DEFAULT_VAULTS: VaultOption[] = [
{ label: 'Getting Started', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
]
interface UseVaultSwitcherOptions {
onSwitch: () => void

View File

@@ -732,5 +732,68 @@ rating: 5
# Designing Data-Intensive Applications
Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing.
`,
'/Users/luca/Laputa/theme/default.md': `---
Is A: Theme
title: Default
primary: "#155DFF"
background: "#FFFFFF"
foreground: "#37352F"
sidebar: "#F7F6F3"
border: "#E9E9E7"
muted: "#F0F0EF"
muted-foreground: "#9B9A97"
accent: "#F0F7FF"
accent-foreground: "#0A3B8F"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14
line-height-base: 1.6
---
# Default
Light theme with warm, paper-like tones.
`,
'/Users/luca/Laputa/theme/dark.md': `---
Is A: Theme
title: Dark
primary: "#155DFF"
background: "#0f0f1a"
foreground: "#e0e0e0"
sidebar: "#1a1a2e"
border: "#2a2a4a"
muted: "#1e1e3a"
muted-foreground: "#6b6b8a"
accent: "#1a2a4a"
accent-foreground: "#8ab4ff"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14
line-height-base: 1.6
---
# Dark
Dark variant with deep navy tones.
`,
'/Users/luca/Laputa/theme/minimal.md': `---
Is A: Theme
title: Minimal
primary: "#000000"
background: "#FAFAFA"
foreground: "#111111"
sidebar: "#F5F5F5"
border: "#E0E0E0"
muted: "#F5F5F5"
muted-foreground: "#888888"
accent: "#F0F0F0"
accent-foreground: "#000000"
font-family: "'SF Mono', 'Menlo', monospace"
font-size-base: 13
line-height-base: 1.5
---
# Minimal
High contrast, minimal chrome.
`,
}

View File

@@ -1121,5 +1121,90 @@ function generateBulkEntries(count: number): VaultEntry[] {
return entries
}
// Theme entries — seeded vault themes
MOCK_ENTRIES.push(
{
path: '/Users/luca/Laputa/theme/default.md',
filename: 'default.md',
title: 'Default',
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 30,
fileSize: 512,
snippet: 'Light theme with warm, paper-like tones.',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
path: '/Users/luca/Laputa/theme/dark.md',
filename: 'dark.md',
title: 'Dark',
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 30,
fileSize: 512,
snippet: 'Dark variant with deep navy tones.',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
{
path: '/Users/luca/Laputa/theme/minimal.md',
filename: 'minimal.md',
title: 'Minimal',
isA: 'Theme',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - 86400 * 30,
createdAt: now - 86400 * 30,
fileSize: 512,
snippet: 'High contrast, minimal chrome.',
wordCount: 10,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null,
outgoingLinks: [],
},
)
// Append 9000 generated entries for realistic large-vault testing
MOCK_ENTRIES.push(...generateBulkEntries(9000))

View File

@@ -245,12 +245,12 @@ export const mockHandlers: Record<string, (args: any) => any> = {
}))
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
},
get_default_vault_path: () => '/Users/mock/Documents/Laputa',
get_default_vault_path: () => '/Users/mock/Documents/Getting Started',
check_vault_exists: (args: { path: string }) => {
// In mock mode, the demo-vault-v2 path always "exists"
return args.path.includes('demo-vault-v2')
},
create_getting_started_vault: () => '/Users/mock/Documents/Laputa',
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
list_themes: (): ThemeFile[] => [...mockThemes],
get_theme: (args: { themeId: string }): ThemeFile => {
@@ -268,6 +268,32 @@ export const mockHandlers: Record<string, (args: any) => any> = {
mockThemes.push({ ...source, id: newId, name: 'Untitled Theme' })
return newId
},
create_vault_theme: (args: { vaultPath: string; name?: string | null }): string => {
const displayName = args.name ?? 'Untitled Theme'
const slug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || 'untitled-theme'
const path = `${args.vaultPath}/theme/${slug}.md`
MOCK_CONTENT[path] = `---
Is A: Theme
title: ${displayName}
primary: "#155DFF"
background: "#FFFFFF"
foreground: "#37352F"
sidebar: "#F7F6F3"
border: "#E9E9E7"
muted: "#F0F0EF"
muted-foreground: "#9B9A97"
accent: "#F0F7FF"
accent-foreground: "#0A3B8F"
font-family: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
font-size-base: 14
line-height-base: 1.6
---
# ${displayName}
`
syncWindowContent()
return path
},
}
export function addMockEntry(_entry: VaultEntry, content: string): void {

View File

@@ -1,7 +1,7 @@
{
"editor": {
"fontFamily": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
"fontSize": 16,
"fontSize": 15,
"lineHeight": 1.5,
"maxWidth": 720,
"paddingHorizontal": 40,
@@ -25,7 +25,7 @@
"marginTop": 28,
"marginBottom": 10,
"color": "var(--text-heading)",
"letterSpacing": 0
"letterSpacing": -0.5
},
"h3": {
"fontSize": 20,
@@ -34,7 +34,7 @@
"marginTop": 24,
"marginBottom": 8,
"color": "var(--text-heading)",
"letterSpacing": 0
"letterSpacing": -0.5
},
"h4": {
"fontSize": 20,

View File

@@ -120,9 +120,12 @@ export interface SearchResponse {
export type SearchMode = 'keyword' | 'semantic' | 'hybrid'
export interface ThemeFile {
/** For vault-based themes: absolute note path. For legacy JSON themes: filename stem. */
id: string
name: string
description: string
/** Absolute path to the vault note (vault-based themes only). */
path?: string
colors: Record<string, string>
typography: Record<string, string>
spacing: Record<string, string>

View File

@@ -395,7 +395,7 @@ export default defineConfig({
// Tauri expects a fixed port
server: {
port: 5201,
port: 5202,
strictPort: true,
allowedHosts: true,
},