Compare commits
48 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a33a000c57 | ||
|
|
f0b456bb8c | ||
|
|
b489fa8e3e | ||
|
|
1a92b4694c | ||
|
|
6b9ff9a4a2 | ||
|
|
97d2182c0e | ||
|
|
80baa74175 | ||
|
|
f71e6d07e7 | ||
|
|
b8a0702e3c | ||
|
|
c48f337c4d | ||
|
|
b32d46f482 | ||
|
|
fd100e1c3b | ||
|
|
249db95c9e | ||
|
|
3c27b63908 | ||
|
|
a246de4483 | ||
|
|
2793024904 | ||
|
|
f0ef9cacec | ||
|
|
d2538e121f | ||
|
|
531666b031 | ||
|
|
e239d71a48 | ||
|
|
e2f1fe239e | ||
|
|
8495f0e2e6 | ||
|
|
19bc3c67e3 | ||
|
|
628d97d909 | ||
|
|
aa6b31317c | ||
|
|
b24ba8a4c6 | ||
|
|
b3f47f4507 | ||
|
|
6fcd0552d0 | ||
|
|
8747a84453 | ||
|
|
2eb3080050 | ||
|
|
99b64c0fac | ||
|
|
b2a68a9a67 | ||
|
|
f5bbcb81a4 | ||
|
|
db5e53f77e | ||
|
|
90e67adc41 | ||
|
|
185feb5a2c | ||
|
|
f04dfdbd37 | ||
|
|
3c27403f86 | ||
|
|
276b3c1a37 | ||
|
|
cadb3500f1 | ||
|
|
ee8f0d6bcd | ||
|
|
41d43501a0 | ||
|
|
32b8e2ee57 | ||
|
|
b1110ead87 | ||
|
|
b75b917538 | ||
|
|
24bb64841e | ||
|
|
df2452dcaf | ||
|
|
89d0e39ad2 |
42
.claude-done
42
.claude-done
@@ -1,42 +0,0 @@
|
||||
# Drag & Drop Images — Implementation Summary
|
||||
|
||||
## Problem
|
||||
Dragging image files from Finder into the BlockNote editor didn't work. Tauri v2's
|
||||
`dragDropEnabled` defaults to `true`, which intercepts OS-level file drops before they
|
||||
reach the webview's HTML5 DnD API — BlockNote never received the dropped files.
|
||||
|
||||
## Solution
|
||||
Instead of disabling Tauri's drag interception, we use Tauri's `onDragDropEvent` API
|
||||
to listen for native file drops and handle them directly:
|
||||
|
||||
1. **Rust backend** (`src-tauri/src/vault/image.rs`):
|
||||
- Added `copy_image_to_vault` command that copies a file by OS path into
|
||||
`vault/attachments/` with a timestamp-prefixed filename
|
||||
- Refactored shared path logic into `prepare_attachment_path` helper
|
||||
- Validates file exists and has an image extension before copying
|
||||
|
||||
2. **Frontend** (`src/hooks/useImageDrop.ts`):
|
||||
- Added Tauri `onDragDropEvent` listener that fires on native OS drag-drop
|
||||
- On `drop`: filters for image paths, copies each to vault, calls `onImageUrl`
|
||||
- On `over`: shows drag overlay (paths aren't available until drop)
|
||||
- `copyImageToVault` invokes the new Rust command and returns an asset URL
|
||||
|
||||
3. **Editor integration** (`src/components/SingleEditorView.tsx`):
|
||||
- `useInsertImageCallback` inserts a BlockNote image block at cursor position
|
||||
- Wired into `useImageDrop` via the `onImageUrl` callback
|
||||
- `vaultPath` threaded through Editor → EditorContent → SingleEditorView
|
||||
|
||||
4. **Existing `/image` slash command** continues to work unchanged (uses `uploadFile`
|
||||
on `useCreateBlockNote` → `uploadImageFile` → `save_image` Tauri command).
|
||||
|
||||
## Commits
|
||||
- `d45f2e2` feat: add copy_image_to_vault Rust command for native drag-drop
|
||||
- `b047d0c` feat: handle Tauri native drag-drop for filesystem images
|
||||
- `2e04f5a` fix: remove nonexistent drag-drop permission from capabilities
|
||||
- `23ad982` fix: correct DragDropEvent type handling for 'over' events
|
||||
|
||||
## Tests
|
||||
- 4 new Rust tests for `copy_image_to_vault` (success, missing file, non-image, all extensions)
|
||||
- 1 new frontend test for `useImageDrop` with onImageUrl + vaultPath params
|
||||
- All 300 Rust tests pass, 1167 frontend tests pass
|
||||
- Coverage: Rust 85.47%, Frontend 78.64%
|
||||
1
.claude-pid
Normal file
1
.claude-pid
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -140,6 +140,9 @@ The pre-push hook runs all checks locally before the push goes through. This rep
|
||||
git push origin main # pre-push hook runs automatically
|
||||
```
|
||||
|
||||
### ⛔ NEVER open a Pull Request
|
||||
PRs on separate branches diverge from main with every merge, requiring continuous rebases and creating unnecessary conflicts. Always push directly to main. If the push fails (disk full, test failure, etc.) — fix the problem, then push again. There is no scenario where opening a PR is the right fallback.
|
||||
|
||||
### ⛔ NEVER use --no-verify
|
||||
```bash
|
||||
# FORBIDDEN — will be caught and rejected:
|
||||
|
||||
1
design/note-templates.pen
Normal file
1
design/note-templates.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/raw-editor-mode.pen
Normal file
1
design/raw-editor-mode.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/tab-responsive-width.pen
Normal file
1
design/tab-responsive-width.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/themes-editable.pen
Normal file
1
design/themes-editable.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
708
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,5 @@
|
||||
packages:
|
||||
- mcp-server
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
45
scripts/bundle-mcp-server.mjs
Normal file
45
scripts/bundle-mcp-server.mjs
Normal 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/')
|
||||
@@ -15,7 +15,7 @@ pub enum FrontmatterValue {
|
||||
|
||||
/// Characters that require a YAML string value to be quoted.
|
||||
fn has_yaml_special_chars(s: &str) -> bool {
|
||||
s.contains(':') || s.contains('#') || s.contains('\n')
|
||||
s.contains(':') || s.contains('#')
|
||||
}
|
||||
|
||||
/// Check if a string starts with a YAML collection indicator (array or map).
|
||||
@@ -41,6 +41,23 @@ fn format_list_item(item: &str) -> String {
|
||||
format!(" - {}", quote_yaml_string(item))
|
||||
}
|
||||
|
||||
/// Format a multi-line string as a YAML block scalar (`|`).
|
||||
/// Each line is indented by 2 spaces; empty lines are preserved as blank.
|
||||
fn format_block_scalar(s: &str) -> String {
|
||||
let indented = s
|
||||
.lines()
|
||||
.map(|l| {
|
||||
if l.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", l)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("|\n{}", indented)
|
||||
}
|
||||
|
||||
/// Format a number for YAML (integers without decimal, floats with).
|
||||
fn format_yaml_number(n: f64) -> String {
|
||||
if n.fract() == 0.0 {
|
||||
@@ -54,7 +71,9 @@ impl FrontmatterValue {
|
||||
pub fn to_yaml_value(&self) -> String {
|
||||
match self {
|
||||
FrontmatterValue::String(s) => {
|
||||
if needs_yaml_quoting(s) {
|
||||
if s.contains('\n') {
|
||||
format_block_scalar(s)
|
||||
} else if needs_yaml_quoting(s) {
|
||||
quote_yaml_string(s)
|
||||
} else {
|
||||
s.clone()
|
||||
@@ -113,16 +132,20 @@ fn line_is_key(line: &str, key: &str) -> bool {
|
||||
fn format_yaml_field(key: &str, value: &FrontmatterValue) -> Vec<String> {
|
||||
let yaml_key = format_yaml_key(key);
|
||||
let yaml_value = value.to_yaml_value();
|
||||
if yaml_value.contains('\n') {
|
||||
if yaml_value.starts_with("|\n") {
|
||||
// Block scalar: key and indicator on the same line, content follows
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
} else if yaml_value.contains('\n') {
|
||||
vec![format!("{}:", yaml_key), yaml_value]
|
||||
} else {
|
||||
vec![format!("{}: {}", yaml_key, yaml_value)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a line is a YAML list continuation (` - ...`) rather than a new key.
|
||||
fn is_list_continuation(line: &str) -> bool {
|
||||
line.starts_with(" - ") || line.starts_with(" -\t")
|
||||
/// Check if a line continues the previous key's value (indented list item,
|
||||
/// block scalar content, or blank line inside a block scalar).
|
||||
fn is_value_continuation(line: &str) -> bool {
|
||||
line.is_empty() || line.starts_with(" ") || line.starts_with('\t')
|
||||
}
|
||||
|
||||
/// Split content into frontmatter body and the rest after the closing `---`.
|
||||
@@ -163,8 +186,8 @@ fn apply_field_update(lines: &[&str], key: &str, value: Option<&FrontmatterValue
|
||||
|
||||
found_key = true;
|
||||
i += 1;
|
||||
// Skip list continuation lines belonging to this key
|
||||
while i < lines.len() && is_list_continuation(lines[i]) {
|
||||
// Skip continuation lines belonging to this key (lists, block scalars)
|
||||
while i < lines.len() && is_value_continuation(lines[i]) {
|
||||
i += 1;
|
||||
}
|
||||
// Insert replacement value (if any)
|
||||
@@ -699,6 +722,94 @@ mod tests {
|
||||
assert!(updated.contains("title: New Title"));
|
||||
}
|
||||
|
||||
// --- block scalar (multi-line string) tests ---
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_multiline_uses_block_scalar() {
|
||||
let v = FrontmatterValue::String("line 1\nline 2\nline 3".to_string());
|
||||
let yaml = v.to_yaml_value();
|
||||
assert!(yaml.starts_with("|\n"));
|
||||
assert!(yaml.contains(" line 1"));
|
||||
assert!(yaml.contains(" line 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_yaml_field_block_scalar() {
|
||||
let v = FrontmatterValue::String("## Objective\n\n## Timeline".to_string());
|
||||
let lines = format_yaml_field("template", &v);
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert!(lines[0].starts_with("template: |\n"));
|
||||
assert!(lines[0].contains(" ## Objective"));
|
||||
assert!(lines[0].contains(" ## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_add() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\n## Timeline";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains("template: |"));
|
||||
assert!(updated.contains(" ## Objective"));
|
||||
assert!(updated.contains(" ## Timeline"));
|
||||
assert!(updated.contains("type: Type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_block_scalar_replace() {
|
||||
let content = "---\ntype: Type\ntemplate: |\n ## Old\n \n ## Stuff\ncolor: green\n---\n# Project\n";
|
||||
let new_template = "## New\n\n## Content";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(new_template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(updated.contains(" ## New"));
|
||||
assert!(updated.contains(" ## Content"));
|
||||
assert!(!updated.contains("## Old"));
|
||||
assert!(!updated.contains("## Stuff"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_frontmatter_block_scalar() {
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Heading\n \n ## Body\ncolor: green\n---\n# Project\n";
|
||||
let updated = update_frontmatter_content(content, "template", None).unwrap();
|
||||
assert!(!updated.contains("template"));
|
||||
assert!(!updated.contains("## Heading"));
|
||||
assert!(updated.contains("color: green"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_block_scalar() {
|
||||
let content = "---\ntype: Type\n---\n# Project\n";
|
||||
let template = "## Objective\n\nDescribe the goal.\n\n## Timeline\n\nKey dates.";
|
||||
let updated = update_frontmatter_content(
|
||||
content,
|
||||
"template",
|
||||
Some(FrontmatterValue::String(template.to_string())),
|
||||
)
|
||||
.unwrap();
|
||||
// Parse back with gray_matter
|
||||
let matter = gray_matter::Matter::<gray_matter::engine::YAML>::new();
|
||||
let parsed = matter.parse(&updated);
|
||||
let data = parsed.data.unwrap();
|
||||
if let gray_matter::Pod::Hash(map) = data {
|
||||
let roundtripped = map.get("template").unwrap().as_string().unwrap();
|
||||
assert!(roundtripped.contains("## Objective"));
|
||||
assert!(roundtripped.contains("## Timeline"));
|
||||
assert!(roundtripped.contains("Describe the goal."));
|
||||
} else {
|
||||
panic!("Expected hash");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_frontmatter_no_body_after_closing() {
|
||||
// Frontmatter with title, no body after closing ---
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod search;
|
||||
pub mod settings;
|
||||
pub mod theme;
|
||||
pub mod vault;
|
||||
pub mod vault_list;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::path::Path;
|
||||
@@ -24,6 +25,7 @@ use search::SearchResponse;
|
||||
use settings::Settings;
|
||||
use theme::{ThemeFile, VaultSettings};
|
||||
use vault::{RenameResult, VaultEntry};
|
||||
use vault_list::VaultList;
|
||||
|
||||
/// Expand a leading `~` or `~/` in a path string to the user's home directory.
|
||||
/// Returns the original string unchanged if it doesn't start with `~` or if the
|
||||
@@ -114,10 +116,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]
|
||||
@@ -277,6 +276,16 @@ fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
settings::save_settings(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_vault_list() -> Result<VaultList, String> {
|
||||
vault_list::load_vault_list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_vault_list(list: VaultList) -> Result<(), String> {
|
||||
vault_list::save_vault_list(&list)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn github_list_repos(token: String) -> Result<Vec<GithubRepo>, String> {
|
||||
github::github_list_repos(&token).await
|
||||
@@ -372,6 +381,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 +413,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 +469,22 @@ mod tests {
|
||||
"expected 'b' prefix, got: {}",
|
||||
result
|
||||
);
|
||||
assert_ne!(result, "b0", "build number should not fall back to 0");
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_ws_bridge(app: &mut tauri::App) {
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
let vp_str = vault_path.to_string_lossy().to_string();
|
||||
match mcp::spawn_ws_bridge(&vp_str) {
|
||||
Ok(child) => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app.state();
|
||||
*state.0.lock().unwrap() = Some(child);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,23 +518,7 @@ pub fn run() {
|
||||
}
|
||||
|
||||
run_startup_tasks();
|
||||
|
||||
// Spawn the MCP WebSocket bridge for the default vault
|
||||
{
|
||||
use tauri::Manager;
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
let vp_str = vault_path.to_string_lossy().to_string();
|
||||
match mcp::spawn_ws_bridge(&vp_str) {
|
||||
Ok(child) => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = app.state();
|
||||
*state.0.lock().unwrap() = Some(child);
|
||||
}
|
||||
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
spawn_ws_bridge(app);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -533,6 +550,8 @@ pub fn run() {
|
||||
get_settings,
|
||||
update_menu_state,
|
||||
save_settings,
|
||||
load_vault_list,
|
||||
save_vault_list,
|
||||
github_list_repos,
|
||||
github_create_repo,
|
||||
clone_repo,
|
||||
@@ -549,7 +568,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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -71,6 +71,36 @@ pub fn save_settings(settings: Settings) -> Result<(), String> {
|
||||
save_settings_at(&settings_path()?, settings)
|
||||
}
|
||||
|
||||
fn last_vault_file() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("last-vault.txt"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn get_last_vault_at(path: &PathBuf) -> Option<String> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn set_last_vault_at(path: &PathBuf, vault_path: &str) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
fs::write(path, vault_path.trim())
|
||||
.map_err(|e| format!("Failed to write last vault path: {}", e))
|
||||
}
|
||||
|
||||
pub fn get_last_vault() -> Option<String> {
|
||||
last_vault_file().ok().and_then(|p| get_last_vault_at(&p))
|
||||
}
|
||||
|
||||
pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
|
||||
set_last_vault_at(&last_vault_file()?, vault_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -199,4 +229,65 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_vault_returns_none_for_missing_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
assert!(get_last_vault_at(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_and_get_last_vault_roundtrip() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/MyVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/MyVault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_trims_whitespace() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, " /Users/test/Vault ").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_last_vault_returns_none_for_empty_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
fs::write(&path, " \n ").unwrap();
|
||||
assert!(get_last_vault_at(&path).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_creates_parent_directories() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/Vault").unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/Vault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_last_vault_overwrites_previous() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("last-vault.txt");
|
||||
set_last_vault_at(&path, "/Users/test/OldVault").unwrap();
|
||||
set_last_vault_at(&path, "/Users/test/NewVault").unwrap();
|
||||
assert_eq!(
|
||||
get_last_vault_at(&path).as_deref(),
|
||||
Some("/Users/test/NewVault")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -65,6 +65,9 @@ pub struct VaultEntry {
|
||||
/// Custom sidebar section label for Type entries, overriding auto-pluralization.
|
||||
#[serde(rename = "sidebarLabel")]
|
||||
pub sidebar_label: Option<String>,
|
||||
/// Markdown template for notes of this Type. When a new note is created
|
||||
/// with this type, the template body is pre-filled after the frontmatter.
|
||||
pub template: Option<String>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
@@ -109,6 +112,8 @@ struct Frontmatter {
|
||||
order: Option<i64>,
|
||||
#[serde(rename = "sidebar label", default)]
|
||||
sidebar_label: Option<String>,
|
||||
#[serde(default)]
|
||||
template: Option<String>,
|
||||
}
|
||||
|
||||
/// Handles YAML fields that can be either a single string or a list of strings.
|
||||
@@ -153,6 +158,7 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"color",
|
||||
"order",
|
||||
"sidebar label",
|
||||
"template",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
@@ -332,6 +338,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
color: frontmatter.color,
|
||||
order: frontmatter.order,
|
||||
sidebar_label: frontmatter.sidebar_label,
|
||||
template: frontmatter.template,
|
||||
word_count,
|
||||
outgoing_links,
|
||||
})
|
||||
@@ -1088,6 +1095,45 @@ References:
|
||||
assert!(entry.relationships.get("sidebar label").is_none());
|
||||
}
|
||||
|
||||
// --- template field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_from_type_entry() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: \"## Objective\\n\\n## Timeline\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_block_scalar() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\ntemplate: |\n ## Objective\n \n ## Timeline\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.template.is_some());
|
||||
let tmpl = entry.template.unwrap();
|
||||
assert!(tmpl.contains("## Objective"));
|
||||
assert!(tmpl.contains("## Timeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_template_missing_defaults_to_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Note\n";
|
||||
let entry = parse_test_entry(&dir, "type/note.md", content);
|
||||
assert_eq!(entry.template, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_not_in_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\ntemplate: \"## Heading\"\n---\n# Project\n";
|
||||
let entry = parse_test_entry(&dir, "type/project.md", content);
|
||||
assert!(entry.relationships.get("template").is_none());
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
140
src-tauri/src/vault_list.rs
Normal file
140
src-tauri/src/vault_list.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct VaultEntry {
|
||||
pub label: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct VaultList {
|
||||
pub vaults: Vec<VaultEntry>,
|
||||
pub active_vault: Option<String>,
|
||||
}
|
||||
|
||||
fn vault_list_path() -> Result<PathBuf, String> {
|
||||
dirs::config_dir()
|
||||
.map(|d| d.join("com.laputa.app").join("vaults.json"))
|
||||
.ok_or_else(|| "Could not determine config directory".to_string())
|
||||
}
|
||||
|
||||
fn load_at(path: &PathBuf) -> Result<VaultList, String> {
|
||||
if !path.exists() {
|
||||
return Ok(VaultList::default());
|
||||
}
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read vault list: {}", e))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse vault list: {}", e))
|
||||
}
|
||||
|
||||
fn save_at(path: &PathBuf, list: &VaultList) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(list)
|
||||
.map_err(|e| format!("Failed to serialize vault list: {}", e))?;
|
||||
fs::write(path, json).map_err(|e| format!("Failed to write vault list: {}", e))
|
||||
}
|
||||
|
||||
pub fn load_vault_list() -> Result<VaultList, String> {
|
||||
load_at(&vault_list_path()?)
|
||||
}
|
||||
|
||||
pub fn save_vault_list(list: &VaultList) -> Result<(), String> {
|
||||
save_at(&vault_list_path()?, list)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn save_and_reload(list: &VaultList) -> VaultList {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("vaults.json");
|
||||
save_at(&path, list).unwrap();
|
||||
load_at(&path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_vault_list_is_empty() {
|
||||
let vl = VaultList::default();
|
||||
assert!(vl.vaults.is_empty());
|
||||
assert!(vl.active_vault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_preserves_data() {
|
||||
let list = VaultList {
|
||||
vaults: vec![
|
||||
VaultEntry {
|
||||
label: "My Vault".to_string(),
|
||||
path: "/Users/luca/Laputa".to_string(),
|
||||
},
|
||||
VaultEntry {
|
||||
label: "Work".to_string(),
|
||||
path: "/Users/luca/Work".to_string(),
|
||||
},
|
||||
],
|
||||
active_vault: Some("/Users/luca/Laputa".to_string()),
|
||||
};
|
||||
let loaded = save_and_reload(&list);
|
||||
assert_eq!(loaded.vaults.len(), 2);
|
||||
assert_eq!(loaded.vaults[0].label, "My Vault");
|
||||
assert_eq!(loaded.vaults[0].path, "/Users/luca/Laputa");
|
||||
assert_eq!(loaded.vaults[1].label, "Work");
|
||||
assert_eq!(loaded.active_vault.as_deref(), Some("/Users/luca/Laputa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_default_for_missing_file() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nonexistent.json");
|
||||
let result = load_at(&path).unwrap();
|
||||
assert!(result.vaults.is_empty());
|
||||
assert!(result.active_vault.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_returns_error_for_malformed_json() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("bad.json");
|
||||
fs::write(&path, "not valid json{{{").unwrap();
|
||||
let err = load_at(&path).unwrap_err();
|
||||
assert!(err.contains("Failed to parse vault list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_creates_parent_directories() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("nested").join("dir").join("vaults.json");
|
||||
let list = VaultList {
|
||||
vaults: vec![VaultEntry {
|
||||
label: "Test".to_string(),
|
||||
path: "/tmp/test".to_string(),
|
||||
}],
|
||||
active_vault: None,
|
||||
};
|
||||
save_at(&path, &list).unwrap();
|
||||
assert!(path.exists());
|
||||
let loaded = load_at(&path).unwrap();
|
||||
assert_eq!(loaded.vaults.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vault_list_path_returns_ok() {
|
||||
let result = vault_list_path();
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_vault_list_roundtrip() {
|
||||
let list = VaultList::default();
|
||||
let loaded = save_and_reload(&list);
|
||||
assert!(loaded.vaults.is_empty());
|
||||
assert!(loaded.active_vault.is_none());
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockEntries = [
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -50,6 +51,7 @@ const mockEntries = [
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -69,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 },
|
||||
}
|
||||
|
||||
69
src/App.tsx
69
src/App.tsx
@@ -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)
|
||||
|
||||
@@ -155,23 +155,33 @@ function App() {
|
||||
navFromHistoryRef.current = false
|
||||
}, [notes.activeTabPath]) // eslint-disable-line react-hooks/exhaustive-deps -- navHistory.push is stable
|
||||
|
||||
const isTabOpen = useCallback((path: string) => notes.tabs.some(t => t.entry.path === path), [notes.tabs])
|
||||
const isEntryExists = useCallback((path: string) => vault.entries.some(e => e.path === path), [vault.entries])
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
const target = navHistory.goBack(isTabOpen)
|
||||
const target = navHistory.goBack(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
notes.handleSwitchTab(target)
|
||||
if (notes.tabs.some(t => t.entry.path === target)) {
|
||||
notes.handleSwitchTab(target)
|
||||
} else {
|
||||
const entry = vault.entries.find(e => e.path === target)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}
|
||||
}
|
||||
}, [navHistory, isTabOpen, notes])
|
||||
}, [navHistory, isEntryExists, vault.entries, notes])
|
||||
|
||||
const handleGoForward = useCallback(() => {
|
||||
const target = navHistory.goForward(isTabOpen)
|
||||
const target = navHistory.goForward(isEntryExists)
|
||||
if (target) {
|
||||
navFromHistoryRef.current = true
|
||||
notes.handleSwitchTab(target)
|
||||
if (notes.tabs.some(t => t.entry.path === target)) {
|
||||
notes.handleSwitchTab(target)
|
||||
} else {
|
||||
const entry = vault.entries.find(e => e.path === target)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}
|
||||
}
|
||||
}, [navHistory, isTabOpen, notes])
|
||||
}, [navHistory, isEntryExists, vault.entries, notes])
|
||||
|
||||
// Mouse button 3/4 (back/forward) and macOS trackpad two-finger swipe
|
||||
useEffect(() => {
|
||||
@@ -235,6 +245,7 @@ function App() {
|
||||
entries: vault.entries, updateEntry: vault.updateEntry,
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
})
|
||||
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
|
||||
@@ -259,10 +270,25 @@ function App() {
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
const rawToggleRef = useRef<() => void>(() => {})
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
|
||||
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,
|
||||
@@ -275,10 +301,11 @@ function App() {
|
||||
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
||||
onSave: handleSave,
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
onTrashNote: entryActions.handleTrashNote, onArchiveNote: entryActions.handleArchiveNote,
|
||||
onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
onToggleRawEditor: () => rawToggleRef.current(),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
|
||||
@@ -288,13 +315,22 @@ 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,
|
||||
onCreateType: dialogs.openCreateType,
|
||||
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
|
||||
@@ -332,7 +368,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} 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} />
|
||||
</>
|
||||
@@ -340,7 +376,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} />
|
||||
</>
|
||||
@@ -378,12 +414,15 @@ function App() {
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
onTitleSync={handleTitleSync}
|
||||
rawToggleRef={rawToggleRef}
|
||||
canGoBack={navHistory.canGoBack}
|
||||
canGoForward={navHistory.canGoForward}
|
||||
onGoBack={handleGoBack}
|
||||
onGoForward={handleGoForward}
|
||||
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
|
||||
isDarkTheme={themeManager.isDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,3 +115,24 @@ describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
expect(onUnarchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Back to editor" tooltip when rawMode is on', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Back to editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleRaw when raw button is clicked', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
fireEvent.click(screen.getByTitle('Raw editor'))
|
||||
expect(onToggleRaw).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
GitBranch,
|
||||
Code,
|
||||
CursorText,
|
||||
Sparkle,
|
||||
SlidersHorizontal,
|
||||
@@ -22,6 +23,8 @@ interface BreadcrumbBarProps {
|
||||
diffMode: boolean
|
||||
diffLoading: boolean
|
||||
onToggleDiff: () => void
|
||||
rawMode?: boolean
|
||||
onToggleRaw?: () => void
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
@@ -34,7 +37,23 @@ interface BreadcrumbBarProps {
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors',
|
||||
rawMode ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={onToggleRaw}
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
>
|
||||
<Code size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onTrash, onRestore, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'noteStatus'>) {
|
||||
@@ -68,6 +87,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
)}
|
||||
<RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -51,6 +51,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -67,6 +67,21 @@
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
/* Override BlockNote's internal color variables so .bn-editor background
|
||||
matches our vault theme instead of BlockNote's hardcoded light/dark defaults. */
|
||||
--bn-colors-editor-background: var(--bg-primary);
|
||||
--bn-colors-editor-text: var(--text-primary);
|
||||
--bn-colors-menu-background: var(--bg-card);
|
||||
--bn-colors-menu-text: var(--text-primary);
|
||||
--bn-colors-tooltip-background: var(--bg-hover);
|
||||
--bn-colors-tooltip-text: var(--text-primary);
|
||||
--bn-colors-hovered-background: var(--bg-hover);
|
||||
--bn-colors-hovered-text: var(--text-primary);
|
||||
--bn-colors-selected-background: var(--bg-selected);
|
||||
--bn-colors-selected-text: var(--text-primary);
|
||||
--bn-colors-border: var(--border-primary);
|
||||
--bn-colors-shadow: var(--border-primary);
|
||||
--bn-colors-side-menu: var(--text-muted);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-editor {
|
||||
|
||||
@@ -75,6 +75,7 @@ const mockEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { FrontmatterValue } from './Inspector'
|
||||
import { ResizeHandle } from './ResizeHandle'
|
||||
import { TabBar } from './TabBar'
|
||||
import { useDiffMode } from '../hooks/useDiffMode'
|
||||
import { useRawMode } from '../hooks/useRawMode'
|
||||
import { useEditorFocus } from '../hooks/useEditorFocus'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
@@ -53,6 +54,7 @@ interface EditorProps {
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
/** Called when H1→title sync updates the title (debounced). */
|
||||
onTitleSync?: (path: string, newTitle: string) => void
|
||||
canGoBack?: boolean
|
||||
@@ -60,6 +62,35 @@ interface EditorProps {
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
leftPanelsCollapsed?: boolean
|
||||
isDarkTheme?: boolean
|
||||
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
|
||||
}: {
|
||||
diffMode: boolean
|
||||
rawMode: boolean
|
||||
handleToggleDiff: () => void | Promise<void>
|
||||
handleToggleRaw: () => void
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
}) {
|
||||
const handleToggleDiffExclusive = useCallback(async () => {
|
||||
if (!diffMode && rawMode) handleToggleRaw()
|
||||
await handleToggleDiff()
|
||||
}, [diffMode, rawMode, handleToggleDiff, handleToggleRaw])
|
||||
|
||||
const handleToggleRawExclusive = useCallback(() => {
|
||||
if (!rawMode && diffMode) handleToggleDiff()
|
||||
handleToggleRaw()
|
||||
}, [rawMode, diffMode, handleToggleDiff, handleToggleRaw])
|
||||
|
||||
useEffect(() => {
|
||||
if (rawToggleRef) rawToggleRef.current = handleToggleRawExclusive
|
||||
}, [rawToggleRef, handleToggleRawExclusive])
|
||||
|
||||
return { handleToggleDiffExclusive, handleToggleRawExclusive }
|
||||
}
|
||||
|
||||
function EditorEmptyState() {
|
||||
@@ -80,8 +111,10 @@ export const Editor = memo(function Editor({
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab, onContentChange, onTitleSync,
|
||||
onRenameTab, onContentChange, onSave, onTitleSync,
|
||||
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
|
||||
isDarkTheme,
|
||||
rawToggleRef,
|
||||
}: EditorProps) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
@@ -114,6 +147,13 @@ export const Editor = memo(function Editor({
|
||||
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({
|
||||
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
|
||||
})
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath })
|
||||
|
||||
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
|
||||
})
|
||||
|
||||
const isLoadingNewTab = activeTabPath !== null && !activeTab
|
||||
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
|
||||
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
|
||||
@@ -147,7 +187,11 @@ export const Editor = memo(function Editor({
|
||||
diffMode={diffMode}
|
||||
diffContent={diffContent}
|
||||
diffLoading={diffLoading}
|
||||
onToggleDiff={handleToggleDiff}
|
||||
onToggleDiff={handleToggleDiffExclusive}
|
||||
rawMode={rawMode}
|
||||
onToggleRaw={handleToggleRawExclusive}
|
||||
onRawContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
activeStatus={activeStatus}
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
@@ -161,6 +205,7 @@ export const Editor = memo(function Editor({
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
isDarkTheme={isDarkTheme}
|
||||
/>
|
||||
}
|
||||
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { DiffView } from './DiffView'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
|
||||
@@ -19,6 +20,10 @@ interface EditorContentProps {
|
||||
diffContent: string | null
|
||||
diffLoading: boolean
|
||||
onToggleDiff: () => void
|
||||
rawMode: boolean
|
||||
onToggleRaw: () => void
|
||||
onRawContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
activeStatus: NoteStatus
|
||||
showDiffToggle: boolean
|
||||
showAIChat?: boolean
|
||||
@@ -32,6 +37,7 @@ interface EditorContentProps {
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
isDarkTheme?: boolean
|
||||
}
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -62,6 +68,28 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
|
||||
)
|
||||
}
|
||||
|
||||
function RawModeEditorSection({
|
||||
rawMode, activeTab, entries, onContentChange, onSave,
|
||||
}: {
|
||||
rawMode: boolean
|
||||
activeTab: Tab | null
|
||||
entries: VaultEntry[]
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
}) {
|
||||
if (!rawMode || !activeTab) return null
|
||||
return (
|
||||
<RawEditorView
|
||||
key={activeTab.entry.path}
|
||||
content={activeTab.content}
|
||||
path={activeTab.entry.path}
|
||||
entries={entries}
|
||||
onContentChange={onContentChange ?? (() => {})}
|
||||
onSave={onSave ?? (() => {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bind an optional callback to a path, returning undefined if callback is absent */
|
||||
function bindPath(cb: ((path: string) => void) | undefined, path: string) {
|
||||
return cb ? () => cb(path) : undefined
|
||||
@@ -69,7 +97,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
|
||||
|
||||
function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
activeTab: Tab
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange'>
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave'>
|
||||
}) {
|
||||
const wordCount = countWords(activeTab.content)
|
||||
const path = activeTab.entry.path
|
||||
@@ -82,6 +110,8 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
diffMode={props.diffMode}
|
||||
diffLoading={props.diffLoading}
|
||||
onToggleDiff={props.onToggleDiff}
|
||||
rawMode={props.rawMode}
|
||||
onToggleRaw={props.onToggleRaw}
|
||||
showAIChat={props.showAIChat}
|
||||
onToggleAIChat={props.onToggleAIChat}
|
||||
inspectorCollapsed={props.inspectorCollapsed}
|
||||
@@ -97,19 +127,28 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
export function EditorContent({
|
||||
activeTab, isLoadingNewTab, entries, editor,
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
onNavigateWikilink, onEditorChange, vaultPath,
|
||||
rawMode, onToggleRaw, onRawContentChange, onSave,
|
||||
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
const showEditor = !diffMode && !rawMode
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-w-0 min-h-0">
|
||||
{activeTab && <ActiveTabBreadcrumb activeTab={activeTab} props={{ diffMode, diffContent, onToggleDiff, ...breadcrumbProps }} />}
|
||||
{activeTab && (
|
||||
<ActiveTabBreadcrumb
|
||||
activeTab={activeTab}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
/>
|
||||
)}
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
{!diffMode && activeTab && (
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
|
||||
{showEditor && activeTab && (
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} />
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingNewTab && !diffMode && <EditorLoadingSkeleton />}
|
||||
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const mockEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -70,6 +71,7 @@ const referrerEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['Test Project'],
|
||||
}
|
||||
|
||||
@@ -374,6 +376,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -400,6 +403,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -426,6 +430,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -452,6 +457,7 @@ This is a test note with some words to count.
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -594,6 +600,7 @@ Status: Active
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
// Body text also links to grow-newsletter
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -62,6 +63,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -87,6 +89,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -112,6 +115,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -137,6 +141,7 @@ const mockEntries: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -357,6 +362,7 @@ describe('getSortComparator', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -469,6 +475,7 @@ describe('NoteList sort controls', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -663,6 +670,7 @@ const trashedEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -689,6 +697,7 @@ const expiredTrashedEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
@@ -844,6 +853,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -986,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 },
|
||||
@@ -1145,6 +1182,7 @@ const typeEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
143
src/components/RawEditorView.test.tsx
Normal file
143
src/components/RawEditorView.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
|
||||
// Minimal VaultEntry factory
|
||||
function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
return {
|
||||
path, filename: `${title}.md`, title, isA: 'Note',
|
||||
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 defaultProps = {
|
||||
content: '---\ntitle: My Note\n---\n\n# My Note\n\nSome content.',
|
||||
path: '/vault/note/my-note.md',
|
||||
entries: [entry('Project Alpha'), entry('Meeting Notes')],
|
||||
onContentChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
|
||||
describe('extractWikilinkQuery', () => {
|
||||
it('returns null when no [[ trigger', () => {
|
||||
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty string immediately after [[', () => {
|
||||
const text = 'see [['
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('')
|
||||
})
|
||||
|
||||
it('returns query after [[', () => {
|
||||
const text = 'see [[Proj'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
|
||||
})
|
||||
|
||||
it('returns null when ]] closes the link', () => {
|
||||
const text = '[[Proj]]'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when newline is in query', () => {
|
||||
const text = '[[Proj\ncontinued'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles cursor before end of text', () => {
|
||||
// cursor at 6 = after "[[Proj" (before the space)
|
||||
const text = '[[Proj after'
|
||||
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectYamlError', () => {
|
||||
it('returns null for content without frontmatter', () => {
|
||||
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid frontmatter', () => {
|
||||
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error for unclosed frontmatter', () => {
|
||||
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
|
||||
expect(error).toContain('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('returns error for tab indentation in frontmatter', () => {
|
||||
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
|
||||
expect(error).toContain('tab indentation')
|
||||
})
|
||||
|
||||
it('returns null for content not starting with ---', () => {
|
||||
expect(detectYamlError('Not frontmatter')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RawEditorView', () => {
|
||||
it('renders textarea with the provided content', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe(defaultProps.content)
|
||||
})
|
||||
|
||||
it('calls onContentChange when user types (debounced)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onContentChange = vi.fn()
|
||||
render(<RawEditorView {...defaultProps} onContentChange={onContentChange} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: '---\ntitle: Changed\n---\n\n# Changed' } })
|
||||
|
||||
// Should not be called immediately
|
||||
expect(onContentChange).not.toHaveBeenCalled()
|
||||
|
||||
// After debounce
|
||||
await act(async () => { vi.advanceTimersByTime(600) })
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
defaultProps.path,
|
||||
'---\ntitle: Changed\n---\n\n# Changed'
|
||||
)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows YAML error banner for unclosed frontmatter', () => {
|
||||
render(<RawEditorView {...defaultProps} content="---\ntitle: Bad\n\n# Title" />)
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('does not show YAML error for valid content', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
expect(screen.queryByTestId('raw-editor-yaml-error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSave when Cmd+S is pressed', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<RawEditorView {...defaultProps} onSave={onSave} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
fireEvent.keyDown(textarea, { key: 's', metaKey: true })
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onSave when Ctrl+S is pressed', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<RawEditorView {...defaultProps} onSave={onSave} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
fireEvent.keyDown(textarea, { key: 's', ctrlKey: true })
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('has monospaced font family applied', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.style.fontFamily).toContain('monospace')
|
||||
})
|
||||
})
|
||||
276
src/components/RawEditorView.tsx
Normal file
276
src/components/RawEditorView.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
/** Get approximate pixel coordinates of the cursor in a textarea. */
|
||||
function getCaretCoordinates(
|
||||
textarea: HTMLTextAreaElement,
|
||||
position: number,
|
||||
): { top: number; left: number } {
|
||||
const mirror = document.createElement('div')
|
||||
const style = getComputedStyle(textarea)
|
||||
|
||||
const props = [
|
||||
'boxSizing', 'width', 'borderTopWidth', 'borderRightWidth',
|
||||
'borderBottomWidth', 'borderLeftWidth',
|
||||
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
||||
'fontStyle', 'fontVariant', 'fontWeight', 'fontSize', 'lineHeight',
|
||||
'fontFamily', 'textTransform', 'letterSpacing', 'wordSpacing',
|
||||
] as const
|
||||
for (const prop of props) {
|
||||
(mirror.style as unknown as Record<string, string>)[prop] = style[prop] as string
|
||||
}
|
||||
mirror.style.position = 'absolute'
|
||||
mirror.style.visibility = 'hidden'
|
||||
mirror.style.top = '0'
|
||||
mirror.style.left = '-9999px'
|
||||
mirror.style.whiteSpace = 'pre-wrap'
|
||||
mirror.style.wordWrap = 'break-word'
|
||||
mirror.style.overflow = 'hidden'
|
||||
|
||||
mirror.textContent = textarea.value.slice(0, position)
|
||||
const caret = document.createElement('span')
|
||||
caret.textContent = '\u200B'
|
||||
mirror.appendChild(caret)
|
||||
document.body.appendChild(mirror)
|
||||
|
||||
const caretRect = caret.getBoundingClientRect()
|
||||
const mirrorRect = mirror.getBoundingClientRect()
|
||||
document.body.removeChild(mirror)
|
||||
|
||||
const textareaRect = textarea.getBoundingClientRect()
|
||||
return {
|
||||
top: caretRect.top - mirrorRect.top + textareaRect.top - textarea.scrollTop,
|
||||
left: caretRect.left - mirrorRect.left + textareaRect.left,
|
||||
}
|
||||
}
|
||||
|
||||
interface AutocompleteState {
|
||||
caretTop: number
|
||||
caretLeft: number
|
||||
selectedIndex: number
|
||||
items: WikilinkSuggestionItem[]
|
||||
}
|
||||
|
||||
interface RawEditorViewProps {
|
||||
content: string
|
||||
path: string
|
||||
entries: VaultEntry[]
|
||||
onContentChange: (path: string, content: string) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const DEBOUNCE_MS = 500
|
||||
const DROPDOWN_MAX_HEIGHT = 200
|
||||
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave }: RawEditorViewProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pathRef = useRef(path)
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
const onSaveRef = useRef(onSave)
|
||||
useEffect(() => { pathRef.current = path }, [path])
|
||||
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
||||
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
||||
|
||||
const [value, setValue] = useState(content)
|
||||
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
|
||||
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
|
||||
// NOTE: tab-switch reset is handled via key={path} in the parent (EditorContent)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
[entries],
|
||||
)
|
||||
|
||||
/** Insert [[entryTitle]] at the current [[ trigger position */
|
||||
const insertWikilink = useCallback((entryTitle: string) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const cursor = textarea.selectionStart
|
||||
const text = textarea.value
|
||||
const before = text.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return
|
||||
|
||||
const after = text.slice(cursor)
|
||||
const newText = `${text.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
|
||||
const newCursor = triggerIdx + entryTitle.length + 4
|
||||
|
||||
setValue(newText)
|
||||
setAutocomplete(null)
|
||||
|
||||
// Flush immediately — autocomplete inserts should not be debounced
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
onContentChangeRef.current(pathRef.current, newText)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = newCursor
|
||||
textareaRef.current.selectionEnd = newCursor
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const cursor = e.target.selectionStart ?? 0
|
||||
|
||||
setValue(newValue)
|
||||
setYamlError(detectYamlError(newValue))
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onContentChangeRef.current(pathRef.current, newValue)
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
const query = extractWikilinkQuery(newValue, cursor)
|
||||
if (query === null || query.length < MIN_QUERY_LENGTH) {
|
||||
setAutocomplete(null)
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = e.target
|
||||
const coords = getCaretCoordinates(textarea, cursor)
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const withHandlers = attachClickHandlers(candidates, insertWikilink)
|
||||
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
||||
|
||||
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
|
||||
}, [baseItems, typeEntryMap, insertWikilink])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Save shortcut
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
onContentChangeRef.current(pathRef.current, textareaRef.current?.value ?? '')
|
||||
}
|
||||
onSaveRef.current()
|
||||
return
|
||||
}
|
||||
|
||||
if (!autocomplete) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setAutocomplete(prev => prev
|
||||
? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, prev.items.length - 1) }
|
||||
: null)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setAutocomplete(prev => prev
|
||||
? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) }
|
||||
: null)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = autocomplete.items[autocomplete.selectedIndex]
|
||||
if (item) insertWikilink(item.entryTitle ?? item.title)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setAutocomplete(null)
|
||||
}
|
||||
}, [autocomplete, insertWikilink])
|
||||
|
||||
const closeAutocomplete = useCallback(() => setAutocomplete(null), [])
|
||||
|
||||
// Flush pending debounce on unmount (e.g. switching tabs while in raw mode)
|
||||
useEffect(() => {
|
||||
const pendingPath = pathRef
|
||||
const pendingChange = onContentChangeRef
|
||||
const pendingDebounce = debounceRef
|
||||
const pendingTextarea = textareaRef
|
||||
return () => {
|
||||
if (pendingDebounce.current) {
|
||||
clearTimeout(pendingDebounce.current)
|
||||
pendingChange.current(pendingPath.current, pendingTextarea.current?.value ?? '')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dropdownBelow = autocomplete
|
||||
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
|
||||
: true
|
||||
const dropdownTop = autocomplete
|
||||
? (dropdownBelow ? autocomplete.caretTop + 20 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 4)
|
||||
: 0
|
||||
const dropdownLeft = autocomplete
|
||||
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }}>
|
||||
{yamlError && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 text-xs border-b shrink-0"
|
||||
style={{ background: '#fef3c7', borderColor: '#d97706', color: '#92400e' }}
|
||||
role="alert"
|
||||
data-testid="raw-editor-yaml-error"
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>YAML error:</span>
|
||||
<span>{yamlError}</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={closeAutocomplete}
|
||||
className="flex-1 resize-none border-none outline-none p-8"
|
||||
style={{
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
background: 'var(--background)',
|
||||
color: 'var(--foreground)',
|
||||
tabSize: 2,
|
||||
minHeight: 0,
|
||||
}}
|
||||
spellCheck={false}
|
||||
aria-label="Raw editor"
|
||||
data-testid="raw-editor-textarea"
|
||||
/>
|
||||
{autocomplete && autocomplete.items.length > 0 && (
|
||||
<div
|
||||
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
|
||||
style={{
|
||||
top: dropdownTop,
|
||||
left: dropdownLeft,
|
||||
maxHeight: DROPDOWN_MAX_HEIGHT,
|
||||
background: 'var(--popover)',
|
||||
borderColor: 'var(--border)',
|
||||
}}
|
||||
data-testid="raw-editor-wikilink-dropdown"
|
||||
>
|
||||
<NoteSearchList
|
||||
items={autocomplete.items}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
|
||||
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
|
||||
},
|
||||
{
|
||||
@@ -63,6 +64,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: ['person/bob'],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -40,6 +40,7 @@ const mockThemeManager: ThemeManager = {
|
||||
themes: [],
|
||||
activeThemeId: null,
|
||||
activeTheme: null,
|
||||
isDark: false,
|
||||
switchTheme: vi.fn(),
|
||||
createTheme: vi.fn().mockResolvedValue('untitled'),
|
||||
reloadThemes: vi.fn(),
|
||||
|
||||
@@ -40,6 +40,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -66,6 +67,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -92,6 +94,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -118,6 +121,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -144,6 +148,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -170,6 +175,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -196,6 +202,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -222,6 +229,7 @@ const mockEntries: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -441,6 +449,7 @@ describe('Sidebar', () => {
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -467,6 +476,7 @@ describe('Sidebar', () => {
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -492,6 +502,7 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -517,6 +528,7 @@ describe('Sidebar', () => {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
@@ -602,6 +614,7 @@ describe('Sidebar', () => {
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
render(<Sidebar entries={[...mockEntries, projectTypeEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
@@ -796,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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,7 +32,9 @@ interface SidebarProps {
|
||||
onCreateType?: (type: string) => void
|
||||
onCreateNewType?: () => void
|
||||
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
|
||||
@@ -150,8 +152,9 @@ function applyCustomization(
|
||||
): void {
|
||||
if (!target || !onCustomizeType) return
|
||||
const te = typeEntryMap[target]
|
||||
if (!te) return
|
||||
const [icon, color] = buildCustomizeArgs(te, prop, value)
|
||||
const [icon, color] = te
|
||||
? buildCustomizeArgs(te, prop, value)
|
||||
: [prop === 'icon' ? value : 'file-text', prop === 'color' ? value : 'blue']
|
||||
onCustomizeType(target, icon, color)
|
||||
}
|
||||
|
||||
@@ -159,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}>
|
||||
@@ -174,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>
|
||||
)
|
||||
@@ -214,25 +221,31 @@ 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 & color…
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: {
|
||||
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChangeTemplate, onClose }: {
|
||||
target: string | null; typeEntryMap: Record<string, VaultEntry>
|
||||
innerRef: React.Ref<HTMLDivElement>
|
||||
onCustomize: (prop: 'icon' | 'color', value: string) => void
|
||||
onChangeTemplate: (template: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!target) return null
|
||||
@@ -241,8 +254,10 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
|
||||
<TypeCustomizePopover
|
||||
currentIcon={typeEntryMap[target]?.icon ?? null}
|
||||
currentColor={typeEntryMap[target]?.color ?? null}
|
||||
currentTemplate={typeEntryMap[target]?.template ?? null}
|
||||
onChangeIcon={(icon) => onCustomize('icon', icon)}
|
||||
onChangeColor={(color) => onCustomize('color', color)}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
@@ -253,11 +268,14 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, 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)
|
||||
|
||||
@@ -298,13 +316,32 @@ 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])
|
||||
|
||||
const handleChangeTemplate = useCallback((template: string) => {
|
||||
if (customizeTarget) onUpdateTypeTemplate?.(customizeTarget, template)
|
||||
}, [customizeTarget, onUpdateTypeTemplate])
|
||||
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename,
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -345,8 +382,8 @@ export const Sidebar = memo(function Sidebar({
|
||||
</nav>
|
||||
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onClose={closeCustomizeTarget} />
|
||||
<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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -23,12 +23,13 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
}
|
||||
|
||||
/** Single BlockNote editor view — content is swapped via replaceBlocks */
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: {
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme }: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
entries: VaultEntry[]
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onChange?: () => void
|
||||
vaultPath?: string
|
||||
isDarkTheme?: boolean
|
||||
}) {
|
||||
const navigateRef = useRef(onNavigateWikilink)
|
||||
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
|
||||
@@ -100,7 +101,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
)}
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme="light"
|
||||
theme={isDarkTheme ? 'dark' : 'light'}
|
||||
onChange={onChange}
|
||||
>
|
||||
<SuggestionMenuController
|
||||
|
||||
@@ -6,6 +6,7 @@ import { openExternalUrl } from '../utils/url'
|
||||
export interface VaultOption {
|
||||
label: string
|
||||
path: string
|
||||
available?: boolean
|
||||
}
|
||||
|
||||
interface StatusBarProps {
|
||||
@@ -29,19 +30,36 @@ interface StatusBarProps {
|
||||
buildNumber?: string
|
||||
}
|
||||
|
||||
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
|
||||
if (isActive) return <Check size={12} />
|
||||
if (unavailable) return <AlertTriangle size={12} style={{ color: 'var(--muted-foreground)' }} />
|
||||
return <span style={{ width: 12 }} />
|
||||
}
|
||||
|
||||
function vaultItemStyle(isActive: boolean, unavailable: boolean): React.CSSProperties {
|
||||
return {
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4,
|
||||
cursor: unavailable ? 'not-allowed' : 'pointer',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
opacity: unavailable ? 0.45 : 1,
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
|
||||
}
|
||||
}
|
||||
|
||||
function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) {
|
||||
const unavailable = vault.available === false
|
||||
const canHover = !isActive && !unavailable
|
||||
return (
|
||||
<div
|
||||
role="button" onClick={onSelect}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', borderRadius: 4, cursor: 'pointer',
|
||||
background: isActive ? 'var(--hover)' : 'transparent',
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted-foreground)', fontSize: 12,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent' }}
|
||||
role="button"
|
||||
onClick={unavailable ? undefined : onSelect}
|
||||
style={vaultItemStyle(isActive, unavailable)}
|
||||
title={unavailable ? `Vault not found: ${vault.path}` : vault.path}
|
||||
onMouseEnter={canHover ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={canHover ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
data-testid={`vault-menu-item-${vault.label}`}
|
||||
>
|
||||
{isActive ? <Check size={12} /> : <span style={{ width: 12 }} />}
|
||||
<VaultMenuIcon isActive={isActive} unavailable={unavailable} />
|
||||
{vault.label}
|
||||
</div>
|
||||
)
|
||||
@@ -116,21 +134,21 @@ const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cu
|
||||
const SEP_STYLE = { color: 'var(--border)' } as const
|
||||
const SYNC_ICON_MAP: Record<string, typeof RefreshCw> = { syncing: Loader2, conflict: AlertTriangle }
|
||||
|
||||
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
|
||||
if (status === 'syncing') return 'Syncing…'
|
||||
if (status === 'conflict') return 'Conflict'
|
||||
if (status === 'error') return 'Sync failed'
|
||||
const SYNC_LABELS: Record<string, string> = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' }
|
||||
const SYNC_COLORS: Record<string, string> = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' }
|
||||
|
||||
function formatElapsedSync(lastSyncTime: number | null): string {
|
||||
if (!lastSyncTime) return 'Not synced'
|
||||
const elapsed = Math.round((Date.now() - lastSyncTime) / 1000)
|
||||
if (elapsed < 60) return 'Synced just now'
|
||||
const mins = Math.floor(elapsed / 60)
|
||||
return `Synced ${mins}m ago`
|
||||
const secs = Math.round((Date.now() - lastSyncTime) / 1000)
|
||||
return secs < 60 ? 'Synced just now' : `Synced ${Math.floor(secs / 60)}m ago`
|
||||
}
|
||||
|
||||
function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string {
|
||||
return SYNC_LABELS[status] ?? formatElapsedSync(lastSyncTime)
|
||||
}
|
||||
|
||||
function syncIconColor(status: SyncStatus): string {
|
||||
if (status === 'conflict') return 'var(--accent-orange)'
|
||||
if (status === 'error') return 'var(--muted-foreground)'
|
||||
return 'var(--accent-green)'
|
||||
return SYNC_COLORS[status] ?? 'var(--accent-green)'
|
||||
}
|
||||
|
||||
function CommitBadge({ info }: { info: LastCommitInfo }) {
|
||||
@@ -156,17 +174,59 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SyncBadge({ status, lastSyncTime, onTriggerSync }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void }) {
|
||||
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
|
||||
const isSyncing = status === 'syncing'
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
onClick={onTriggerSync}
|
||||
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={isSyncing ? 'Syncing…' : 'Click to sync now'}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ConflictBadge({ count }: { count: number }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
|
||||
<AlertTriangle size={13} />{count} conflict{count > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="View pending changes"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-modified-count"
|
||||
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{count} pending</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber }: StatusBarProps) {
|
||||
// Force re-render every 30s to keep relative time label fresh
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const syncLabel = formatSyncLabel(syncStatus, lastSyncTime)
|
||||
const SyncIcon = SYNC_ICON_MAP[syncStatus] ?? RefreshCw
|
||||
|
||||
return (
|
||||
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
@@ -174,38 +234,10 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={ICON_STYLE} data-testid="status-build-number"><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onTriggerSync}
|
||||
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={syncStatus === 'syncing' ? 'Syncing…' : 'Click to sync now'}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(syncStatus) }} className={syncStatus === 'syncing' ? 'animate-spin' : ''} />{syncLabel}
|
||||
</span>
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
{conflictCount > 0 && (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
|
||||
<AlertTriangle size={13} />{conflictCount} conflict{conflictCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{modifiedCount > 0 && (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClickPending}
|
||||
style={{ ...ICON_STYLE, cursor: 'pointer', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="View pending changes"
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
data-testid="status-modified-count"
|
||||
><CircleDot size={13} style={{ color: 'var(--accent-orange)' }} />{modifiedCount} pending</span>
|
||||
</>
|
||||
)}
|
||||
<ConflictBadge count={conflictCount} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { TabBar } from './TabBar'
|
||||
import { computeTabMaxWidth } from '../utils/tabLayout'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(path: string, title: string): VaultEntry {
|
||||
@@ -10,7 +11,7 @@ function makeEntry(path: string, title: string): VaultEntry {
|
||||
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, outgoingLinks: [],
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +216,33 @@ describe('TabBar', () => {
|
||||
expect(screen.queryByTestId('tab-new-indicator')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders nav back/forward buttons', () => {
|
||||
const tabs = makeTabs(['Alpha'])
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />)
|
||||
expect(screen.getByTestId('nav-back')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('nav-forward')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables nav buttons when canGoBack/canGoForward are false', () => {
|
||||
const tabs = makeTabs(['Alpha'])
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} canGoBack={false} canGoForward={false} />)
|
||||
expect(screen.getByTestId('nav-back')).toBeDisabled()
|
||||
expect(screen.getByTestId('nav-forward')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables nav buttons and fires handlers on click', () => {
|
||||
const onGoBack = vi.fn()
|
||||
const onGoForward = vi.fn()
|
||||
const tabs = makeTabs(['Alpha'])
|
||||
render(<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} canGoBack canGoForward onGoBack={onGoBack} onGoForward={onGoForward} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('nav-back'))
|
||||
expect(onGoBack).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByTestId('nav-forward'))
|
||||
expect(onGoForward).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('switches tab on click', () => {
|
||||
const onSwitchTab = vi.fn()
|
||||
const tabs = makeTabs(['Alpha', 'Beta'])
|
||||
@@ -230,4 +258,59 @@ describe('TabBar', () => {
|
||||
fireEvent.click(screen.getByText('Beta'))
|
||||
expect(onSwitchTab).toHaveBeenCalledWith(tabs[1].entry.path)
|
||||
})
|
||||
|
||||
describe('responsive tab width', () => {
|
||||
it('wraps tabs in an overflow-hidden flex container', () => {
|
||||
const tabs = makeTabs(['Alpha'])
|
||||
const { container } = render(
|
||||
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
|
||||
)
|
||||
const tabArea = container.querySelector('.overflow-hidden')
|
||||
expect(tabArea).toBeInTheDocument()
|
||||
expect(tabArea?.classList.contains('flex')).toBe(true)
|
||||
expect(tabArea?.classList.contains('min-w-0')).toBe(true)
|
||||
expect(tabArea?.classList.contains('flex-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('tab elements are shrinkable with min-w-0', () => {
|
||||
const tabs = makeTabs(['Alpha', 'Beta'])
|
||||
const { container } = render(
|
||||
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
|
||||
)
|
||||
const tabEls = container.querySelectorAll('[draggable="true"]')
|
||||
expect(tabEls).toHaveLength(2)
|
||||
for (const el of tabEls) {
|
||||
expect(el.classList.contains('shrink-0')).toBe(false)
|
||||
expect(el.classList.contains('min-w-0')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTabMaxWidth', () => {
|
||||
it('caps at 360px when container is wide', () => {
|
||||
expect(computeTabMaxWidth(1200, 2)).toBe(360)
|
||||
})
|
||||
|
||||
it('divides space equally among tabs', () => {
|
||||
expect(computeTabMaxWidth(500, 5)).toBe(100)
|
||||
})
|
||||
|
||||
it('enforces minimum of 60px', () => {
|
||||
expect(computeTabMaxWidth(300, 10)).toBe(60)
|
||||
})
|
||||
|
||||
it('returns 360 for zero tabs', () => {
|
||||
expect(computeTabMaxWidth(800, 0)).toBe(360)
|
||||
})
|
||||
|
||||
it('floors the result to integer pixels', () => {
|
||||
// 1000 / 3 = 333.33 → 333
|
||||
expect(computeTabMaxWidth(1000, 3)).toBe(333)
|
||||
})
|
||||
|
||||
it('handles single tab', () => {
|
||||
expect(computeTabMaxWidth(200, 1)).toBe(200)
|
||||
expect(computeTabMaxWidth(500, 1)).toBe(360)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { VaultEntry, NoteStatus } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
import { Plus, Columns, ArrowsOutSimple, ArrowLeft, ArrowRight } from '@phosphor-icons/react'
|
||||
import { computeTabMaxWidth } from '@/utils/tabLayout'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -199,7 +200,7 @@ function StatusDot({ status }: { status: NoteStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, tabMaxWidth, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
tab: Tab
|
||||
isActive: boolean
|
||||
isEditing: boolean
|
||||
@@ -207,6 +208,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
isDragging: boolean
|
||||
showDropBefore: boolean
|
||||
showDropAfter: boolean
|
||||
tabMaxWidth: number
|
||||
onSwitch: () => void
|
||||
onClose: () => void
|
||||
onDoubleClick: () => void
|
||||
@@ -219,10 +221,11 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
draggable={!isEditing}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[360px] transition-all relative",
|
||||
"group flex min-w-0 items-center gap-1.5 whitespace-nowrap transition-all relative",
|
||||
isActive ? "text-foreground" : "text-muted-foreground hover:text-secondary-foreground"
|
||||
)}
|
||||
style={{
|
||||
maxWidth: tabMaxWidth,
|
||||
background: isActive ? 'var(--background)' : 'transparent',
|
||||
borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`,
|
||||
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
|
||||
@@ -331,8 +334,20 @@ export const TabBar = memo(function TabBar({
|
||||
}: TabBarProps) {
|
||||
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null)
|
||||
const tabAreaRef = useRef<HTMLDivElement>(null)
|
||||
const [tabMaxWidth, setTabMaxWidth] = useState(360)
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabAreaRef.current
|
||||
if (!el) return
|
||||
const recalc = () => setTabMaxWidth(computeTabMaxWidth(el.clientWidth, tabs.length))
|
||||
recalc()
|
||||
const observer = new ResizeObserver(recalc)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [tabs.length])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-stretch"
|
||||
@@ -340,30 +355,33 @@ export const TabBar = memo(function TabBar({
|
||||
onDragLeave={handleBarDragLeave}
|
||||
>
|
||||
<NavButtons canGoBack={canGoBack} canGoForward={canGoForward} onGoBack={onGoBack} onGoForward={onGoForward} />
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.entry.path}
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isEditing={editingPath === tab.entry.path}
|
||||
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
onSwitch={() => onSwitchTab(tab.entry.path)}
|
||||
onClose={() => onCloseTab(tab.entry.path)}
|
||||
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
|
||||
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
|
||||
onRenameCancel={() => setEditingPath(null)}
|
||||
dragProps={{
|
||||
onDragStart: (e) => handleDragStart(e, index),
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragOver: (e) => handleDragOver(e, index),
|
||||
onDrop: handleDrop,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-1" style={{ borderBottom: '1px solid var(--border)', cursor: 'default' }} onMouseDown={onDragMouseDown} />
|
||||
<div ref={tabAreaRef} className="flex flex-1 min-w-0 items-stretch overflow-hidden">
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.entry.path}
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isEditing={editingPath === tab.entry.path}
|
||||
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
tabMaxWidth={tabMaxWidth}
|
||||
onSwitch={() => onSwitchTab(tab.entry.path)}
|
||||
onClose={() => onCloseTab(tab.entry.path)}
|
||||
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
|
||||
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
|
||||
onRenameCancel={() => setEditingPath(null)}
|
||||
dragProps={{
|
||||
onDragStart: (e) => handleDragStart(e, index),
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragOver: (e) => handleDragOver(e, index),
|
||||
onDrop: handleDrop,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-1 shrink-0" style={{ borderBottom: '1px solid var(--border)', cursor: 'default' }} onMouseDown={onDragMouseDown} />
|
||||
</div>
|
||||
<TabBarActions onCreateNote={onCreateNote} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -43,50 +43,42 @@ describe('ICON_OPTIONS', () => {
|
||||
describe('TypeCustomizePopover', () => {
|
||||
const onChangeIcon = vi.fn()
|
||||
const onChangeColor = vi.fn()
|
||||
const onChangeTemplate = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
const renderPopover = (overrides: Partial<Parameters<typeof TypeCustomizePopover>[0]> = {}) =>
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
currentTemplate={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onChangeTemplate={onChangeTemplate}
|
||||
onClose={onClose}
|
||||
{...overrides}
|
||||
/>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders color section and icon section', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon="wrench"
|
||||
currentColor="blue"
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
it('renders color, icon, and template sections', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByText('COLOR')).toBeInTheDocument()
|
||||
expect(screen.getByText('ICON')).toBeInTheDocument()
|
||||
expect(screen.getByText('TEMPLATE')).toBeInTheDocument()
|
||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders search input', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
expect(screen.getByPlaceholderText('Search icons…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters icons by search query', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search icons…')
|
||||
fireEvent.change(searchInput, { target: { value: 'book' } })
|
||||
@@ -99,15 +91,7 @@ describe('TypeCustomizePopover', () => {
|
||||
})
|
||||
|
||||
it('shows empty state when no icons match search', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search icons…')
|
||||
fireEvent.change(searchInput, { target: { value: 'zzzznonexistent' } })
|
||||
@@ -116,15 +100,7 @@ describe('TypeCustomizePopover', () => {
|
||||
})
|
||||
|
||||
it('calls onChangeColor when a color is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i)
|
||||
fireEvent.click(colorButtons[0])
|
||||
@@ -133,47 +109,70 @@ describe('TypeCustomizePopover', () => {
|
||||
})
|
||||
|
||||
it('calls onChangeIcon when an icon is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
fireEvent.click(screen.getByTitle('wrench'))
|
||||
expect(onChangeIcon).toHaveBeenCalledWith('wrench')
|
||||
})
|
||||
|
||||
it('calls onClose when Done is clicked', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
fireEvent.click(screen.getByText('Done'))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders all color options including teal and pink', () => {
|
||||
render(
|
||||
<TypeCustomizePopover
|
||||
currentIcon={null}
|
||||
currentColor={null}
|
||||
onChangeIcon={onChangeIcon}
|
||||
onChangeColor={onChangeColor}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
renderPopover()
|
||||
|
||||
expect(screen.getByTitle('Teal')).toBeInTheDocument()
|
||||
expect(screen.getByTitle('Pink')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// --- Template tests ---
|
||||
|
||||
it('renders template textarea', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByTestId('template-textarea')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows placeholder when template is empty', () => {
|
||||
renderPopover()
|
||||
expect(screen.getByPlaceholderText('Markdown template for new notes of this type…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays current template value', () => {
|
||||
renderPopover({ currentTemplate: '## Objective\n\n## Notes' })
|
||||
const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('## Objective\n\n## Notes')
|
||||
})
|
||||
|
||||
it('updates template text on user input', () => {
|
||||
renderPopover()
|
||||
const textarea = screen.getByTestId('template-textarea')
|
||||
fireEvent.change(textarea, { target: { value: '## New Template' } })
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe('## New Template')
|
||||
})
|
||||
|
||||
it('calls onChangeTemplate after debounce', async () => {
|
||||
vi.useFakeTimers()
|
||||
renderPopover()
|
||||
const textarea = screen.getByTestId('template-textarea')
|
||||
fireEvent.change(textarea, { target: { value: '## Debounced' } })
|
||||
|
||||
// Should not be called immediately
|
||||
expect(onChangeTemplate).not.toHaveBeenCalled()
|
||||
|
||||
// Fast-forward past debounce
|
||||
vi.advanceTimersByTime(600)
|
||||
expect(onChangeTemplate).toHaveBeenCalledWith('## Debounced')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('treats null template as empty string', () => {
|
||||
renderPopover({ currentTemplate: null })
|
||||
const textarea = screen.getByTestId('template-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||
import { MagnifyingGlass } from '@phosphor-icons/react'
|
||||
import { ICON_OPTIONS, type IconEntry } from '../utils/iconRegistry'
|
||||
import { ACCENT_COLORS } from '../utils/typeColors'
|
||||
@@ -13,21 +13,40 @@ function filterIcons(icons: IconEntry[], query: string): IconEntry[] {
|
||||
interface TypeCustomizePopoverProps {
|
||||
currentIcon: string | null
|
||||
currentColor: string | null
|
||||
currentTemplate: string | null
|
||||
onChangeIcon: (icon: string) => void
|
||||
onChangeColor: (color: string) => void
|
||||
onChangeTemplate: (template: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Debounce a callback by `delay` ms. Returns a stable ref-based wrapper. */
|
||||
function useDebouncedCallback(fn: (v: string) => void, delay: number): (v: string) => void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
const fnRef = useRef(fn)
|
||||
useEffect(() => { fnRef.current = fn })
|
||||
|
||||
useEffect(() => () => { clearTimeout(timerRef.current) }, [])
|
||||
|
||||
return useCallback((v: string) => {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => fnRef.current(v), delay)
|
||||
}, [delay])
|
||||
}
|
||||
|
||||
export function TypeCustomizePopover({
|
||||
currentIcon,
|
||||
currentColor,
|
||||
currentTemplate,
|
||||
onChangeIcon,
|
||||
onChangeColor,
|
||||
onChangeTemplate,
|
||||
onClose,
|
||||
}: TypeCustomizePopoverProps) {
|
||||
const [selectedColor, setSelectedColor] = useState(currentColor)
|
||||
const [selectedIcon, setSelectedIcon] = useState(currentIcon)
|
||||
const [search, setSearch] = useState('')
|
||||
const [templateText, setTemplateText] = useState(currentTemplate ?? '')
|
||||
|
||||
const filteredIcons = useMemo(() => filterIcons(ICON_OPTIONS, search), [search])
|
||||
|
||||
@@ -41,10 +60,17 @@ export function TypeCustomizePopover({
|
||||
onChangeIcon(name)
|
||||
}
|
||||
|
||||
const debouncedSaveTemplate = useDebouncedCallback(onChangeTemplate, 500)
|
||||
|
||||
const handleTemplateChange = (value: string) => {
|
||||
setTemplateText(value)
|
||||
debouncedSaveTemplate(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground z-50 rounded-lg border shadow-md"
|
||||
style={{ width: 280, padding: 12 }}
|
||||
style={{ width: 320, padding: 12 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -84,7 +110,7 @@ export function TypeCustomizePopover({
|
||||
</div>
|
||||
|
||||
{/* Icon grid */}
|
||||
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 240 }}>
|
||||
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 160 }}>
|
||||
{filteredIcons.length === 0 ? (
|
||||
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
|
||||
No icons found
|
||||
@@ -109,6 +135,17 @@ export function TypeCustomizePopover({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Template section */}
|
||||
<div className="font-mono-overline mb-2 mt-3 text-muted-foreground">TEMPLATE</div>
|
||||
<textarea
|
||||
value={templateText}
|
||||
onChange={(e) => handleTemplateChange(e.target.value)}
|
||||
placeholder="Markdown template for new notes of this type…"
|
||||
className="w-full rounded border border-border bg-background px-2 py-1.5 text-[12px] font-mono text-foreground placeholder:text-muted-foreground outline-none focus:border-primary resize-y"
|
||||
style={{ minHeight: 80, maxHeight: 200 }}
|
||||
data-testid="template-textarea"
|
||||
/>
|
||||
|
||||
{/* Done button */}
|
||||
<div className="mt-3 flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useAppKeyboard } from './useAppKeyboard'
|
||||
import { useCommandRegistry } from './useCommandRegistry'
|
||||
import type { CommandAction } from './useCommandRegistry'
|
||||
@@ -26,11 +27,13 @@ interface AppCommandsConfig {
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
onRestoreNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
@@ -48,12 +51,30 @@ interface AppCommandsConfig {
|
||||
activeThemeId?: string | null
|
||||
onSwitchTheme?: (themeId: string) => void
|
||||
onCreateTheme?: () => void
|
||||
onOpenTheme?: (themeId: string) => void
|
||||
onOpenVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
isUpdating?: boolean
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
const entriesRef = useRef(config.entries)
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
entriesRef.current = config.entries
|
||||
|
||||
const toggleArchive = useCallback((path: string) => {
|
||||
const entry = entriesRef.current.find(e => e.path === path)
|
||||
;(entry?.archived ? config.onUnarchiveNote : config.onArchiveNote)(path)
|
||||
}, [config.onArchiveNote, config.onUnarchiveNote])
|
||||
|
||||
const toggleTrash = useCallback((path: string) => {
|
||||
const entry = entriesRef.current.find(e => e.path === path)
|
||||
;(entry?.trashed ? config.onRestoreNote : config.onTrashNote)(path)
|
||||
}, [config.onTrashNote, config.onRestoreNote])
|
||||
|
||||
useAppKeyboard({
|
||||
onQuickOpen: config.onQuickOpen,
|
||||
onCommandPalette: config.onCommandPalette,
|
||||
@@ -62,8 +83,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onOpenDailyNote: config.onOpenDailyNote,
|
||||
onSave: config.onSave,
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
onTrashNote: config.onTrashNote,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onTrashNote: toggleTrash,
|
||||
onArchiveNote: toggleArchive,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
@@ -71,6 +92,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,
|
||||
})
|
||||
@@ -87,8 +109,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onTrashNote: config.onTrashNote,
|
||||
onArchiveNote: toggleArchive,
|
||||
onTrashNote: toggleTrash,
|
||||
onSearch: config.onSearch,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
@@ -107,11 +129,13 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onSave: config.onSave,
|
||||
onOpenSettings: config.onOpenSettings,
|
||||
onTrashNote: config.onTrashNote,
|
||||
onRestoreNote: config.onRestoreNote,
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onUnarchiveNote: config.onUnarchiveNote,
|
||||
onCommitPush: config.onCommitPush,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
@@ -127,8 +151,12 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
activeThemeId: config.activeThemeId,
|
||||
onSwitchTheme: config.onSwitchTheme,
|
||||
onCreateTheme: config.onCreateTheme,
|
||||
onOpenTheme: config.onOpenTheme,
|
||||
onOpenVault: config.onOpenVault,
|
||||
onCreateType: config.onCreateType,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onCheckForUpdates: config.onCheckForUpdates,
|
||||
isUpdating: config.isUpdating,
|
||||
})
|
||||
|
||||
useKeyboardNavigation({
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -41,6 +42,7 @@ function makeConfig(overrides: Record<string, unknown> = {}) {
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onTrashNote: vi.fn(),
|
||||
onRestoreNote: vi.fn(),
|
||||
onArchiveNote: vi.fn(),
|
||||
onUnarchiveNote: vi.fn(),
|
||||
onCommitPush: vi.fn(),
|
||||
@@ -116,6 +118,74 @@ describe('useCommandRegistry', () => {
|
||||
expect(archiveCmd!.label).toBe('Archive Note')
|
||||
})
|
||||
|
||||
it('shows "Restore Note" when active note is trashed', () => {
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
|
||||
)
|
||||
const trashCmd = result.current.find(c => c.id === 'trash-note')
|
||||
expect(trashCmd!.label).toBe('Restore Note')
|
||||
})
|
||||
|
||||
it('shows "Trash Note" when active note is not trashed', () => {
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries })),
|
||||
)
|
||||
const trashCmd = result.current.find(c => c.id === 'trash-note')
|
||||
expect(trashCmd!.label).toBe('Trash Note')
|
||||
})
|
||||
|
||||
it('calls onRestoreNote when trash command executes on trashed note', () => {
|
||||
const onRestoreNote = vi.fn()
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: true })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onRestoreNote })),
|
||||
)
|
||||
result.current.find(c => c.id === 'trash-note')!.execute()
|
||||
expect(onRestoreNote).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it('calls onTrashNote when trash command executes on non-trashed note', () => {
|
||||
const onTrashNote = vi.fn()
|
||||
const entries = [makeEntry({ path: '/vault/note/test.md', trashed: false })]
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', entries, onTrashNote })),
|
||||
)
|
||||
result.current.find(c => c.id === 'trash-note')!.execute()
|
||||
expect(onTrashNote).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it('has toggle-raw-editor command in View group', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('View')
|
||||
})
|
||||
|
||||
it('disables toggle-raw-editor when no note is open', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('enables toggle-raw-editor when a note is open', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md' })),
|
||||
)
|
||||
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onToggleRawEditor when toggle-raw-editor executes', () => {
|
||||
const onToggleRawEditor = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', onToggleRawEditor })),
|
||||
)
|
||||
result.current.find(c => c.id === 'toggle-raw-editor')!.execute()
|
||||
expect(onToggleRawEditor).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables commit when no modified files', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 })))
|
||||
expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false)
|
||||
@@ -211,6 +281,76 @@ 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('create-type command', () => {
|
||||
it('has create-type command in Note group when onCreateType is provided', () => {
|
||||
const onCreateType = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
|
||||
const cmd = result.current.find(c => c.id === 'create-type')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.label).toBe('New Type')
|
||||
expect(cmd!.group).toBe('Note')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled when onCreateType is not provided', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'create-type')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls onCreateType when executed', () => {
|
||||
const onCreateType = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
|
||||
result.current.find(c => c.id === 'create-type')!.execute()
|
||||
expect(onCreateType).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('has relevant keywords for discoverability', () => {
|
||||
const onCreateType = vi.fn()
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ onCreateType })))
|
||||
const cmd = result.current.find(c => c.id === 'create-type')
|
||||
expect(cmd!.keywords).toContain('new')
|
||||
expect(cmd!.keywords).toContain('create')
|
||||
expect(cmd!.keywords).toContain('type')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type-aware commands', () => {
|
||||
it('generates "New [Type]" commands from vault entries', () => {
|
||||
const entries = [
|
||||
@@ -367,6 +507,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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -25,13 +25,18 @@ interface CommandRegistryConfig {
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
onOpenVault?: () => void
|
||||
onCreateType?: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
onRestoreNote: (path: string) => void
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
isUpdating?: boolean
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
@@ -47,6 +52,7 @@ interface CommandRegistryConfig {
|
||||
activeThemeId?: string | null
|
||||
onSwitchTheme?: (themeId: string) => void
|
||||
onCreateTheme?: () => void
|
||||
onOpenTheme?: (themeId: string) => void
|
||||
}
|
||||
|
||||
const PLURAL_OVERRIDES: Record<string, string> = {
|
||||
@@ -101,39 +107,79 @@ export function buildTypeCommands(
|
||||
})
|
||||
}
|
||||
|
||||
export function buildViewCommands(
|
||||
hasActiveNote: boolean,
|
||||
onSetViewMode: (mode: ViewMode) => void,
|
||||
onToggleInspector: () => void,
|
||||
onToggleRawEditor: (() => void) | undefined,
|
||||
onToggleAIChat: (() => void) | undefined,
|
||||
zoomLevel: number,
|
||||
onZoomIn: () => void,
|
||||
onZoomOut: () => void,
|
||||
onZoomReset: () => void,
|
||||
): CommandAction[] {
|
||||
return [
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
]
|
||||
}
|
||||
|
||||
export function buildThemeCommands(
|
||||
themes: ThemeFile[] | undefined,
|
||||
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[] {
|
||||
const {
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
themes, activeThemeId, onSwitchTheme, onCreateTheme,
|
||||
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
|
||||
onCheckForUpdates, isUpdating,
|
||||
onCreateType,
|
||||
} = config
|
||||
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
@@ -143,6 +189,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
[entries, activeTabPath, hasActiveNote],
|
||||
)
|
||||
const isArchived = activeEntry?.archived ?? false
|
||||
const isTrashed = activeEntry?.trashed ?? false
|
||||
|
||||
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
|
||||
|
||||
@@ -160,10 +207,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
|
||||
// Note actions (contextual)
|
||||
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
|
||||
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
|
||||
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
|
||||
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },
|
||||
{ id: 'close-tab', label: 'Close Tab', group: 'Note', shortcut: '⌘W', keywords: [], enabled: hasActiveNote, execute: () => { if (activeTabPath) onCloseTab(activeTabPath) } },
|
||||
{ id: 'trash-note', label: 'Trash Note', group: 'Note', shortcut: '⌘⌫', keywords: ['delete', 'remove'], enabled: hasActiveNote, execute: () => { if (activeTabPath) onTrashNote(activeTabPath) } },
|
||||
{
|
||||
id: 'trash-note', label: isTrashed ? 'Restore Note' : 'Trash Note', group: 'Note', shortcut: '⌘⌫',
|
||||
keywords: ['delete', 'remove', 'restore', 'trash'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isTrashed ? onRestoreNote : onTrashNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'archive-note', label: isArchived ? 'Unarchive Note' : 'Archive Note', group: 'Note', shortcut: '⌘E',
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
@@ -175,21 +227,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
|
||||
// View
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
...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),
|
||||
@@ -197,13 +243,14 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
|
||||
return cmds
|
||||
}, [
|
||||
hasActiveNote, activeTabPath, isArchived, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, 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,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -2,19 +2,33 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useEditorFocus } from './useEditorFocus'
|
||||
|
||||
function makeTiptapMock(hasHeading = true) {
|
||||
const chainResult = { setTextSelection: vi.fn().mockReturnThis(), run: vi.fn() }
|
||||
const descendantsMock = vi.fn().mockImplementation((cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => {
|
||||
if (hasHeading) cb({ type: { name: 'heading' }, nodeSize: 15 }, 2)
|
||||
})
|
||||
return {
|
||||
state: { doc: { descendants: descendantsMock } },
|
||||
chain: vi.fn(() => chainResult),
|
||||
_chainResult: chainResult,
|
||||
_descendantsMock: descendantsMock,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEditorFocus', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
function setup(isMounted: boolean) {
|
||||
const editor = { focus: vi.fn() }
|
||||
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn(), _tiptapEditor: tiptap } as any
|
||||
const mountedRef = { current: isMounted }
|
||||
renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
return editor
|
||||
return { editor, tiptap }
|
||||
}
|
||||
|
||||
it('focuses editor via rAF when already mounted', async () => {
|
||||
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const editor = setup(true)
|
||||
const { editor } = setup(true)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
@@ -24,7 +38,7 @@ describe('useEditorFocus', () => {
|
||||
|
||||
it('focuses editor via setTimeout when not yet mounted', () => {
|
||||
vi.useFakeTimers()
|
||||
const editor = setup(false)
|
||||
const { editor } = setup(false)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
@@ -35,7 +49,8 @@ describe('useEditorFocus', () => {
|
||||
})
|
||||
|
||||
it('cleans up event listener on unmount', () => {
|
||||
const editor = { focus: vi.fn() }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn() } as any
|
||||
const mountedRef = { current: true }
|
||||
const { unmount } = renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
|
||||
@@ -45,4 +60,102 @@ describe('useEditorFocus', () => {
|
||||
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('selectTitle behavior', () => {
|
||||
it('selects H1 text when selectTitle is true and editor is mounted', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).toHaveBeenCalled()
|
||||
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
|
||||
expect(tiptap._chainResult.run).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not select title when selectTitle is false (default)', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: false } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not select title when selectTitle is absent from event detail', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips selection when no heading found in document', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(false)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(80)
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).toHaveBeenCalled()
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,67 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface TiptapChain {
|
||||
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
||||
run: () => void
|
||||
}
|
||||
|
||||
interface TiptapEditor {
|
||||
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
|
||||
chain: () => TiptapChain
|
||||
}
|
||||
|
||||
/** Select all text in the first heading block via the TipTap chain API. */
|
||||
function selectFirstHeading(editor: { _tiptapEditor?: TiptapEditor }): void {
|
||||
const tiptap = editor._tiptapEditor
|
||||
if (!tiptap?.state?.doc) return
|
||||
|
||||
let from = -1
|
||||
let to = -1
|
||||
|
||||
tiptap.state.doc.descendants((node, pos) => {
|
||||
if (from !== -1) return false
|
||||
if (node.type.name === 'heading') {
|
||||
from = pos + 1
|
||||
to = pos + node.nodeSize - 1
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (from === -1 || from >= to) return
|
||||
tiptap.chain().setTextSelection({ from, to }).run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus editor when a new note is created (signaled via custom event).
|
||||
* Uses adaptive timing: fast rAF path when editor is already mounted,
|
||||
* short timeout when waiting for first mount.
|
||||
* When selectTitle is true, also selects all text in the first H1 block.
|
||||
*/
|
||||
export function useEditorFocus(
|
||||
editor: { focus: () => void },
|
||||
editor: { focus: () => void; _tiptapEditor?: TiptapEditor },
|
||||
editorMountedRef: React.RefObject<boolean>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const t0 = (e as CustomEvent).detail?.t0 as number | undefined
|
||||
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean } | undefined
|
||||
const t0 = detail?.t0
|
||||
const selectTitle = detail?.selectTitle ?? false
|
||||
const doFocus = () => {
|
||||
editor.focus()
|
||||
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)
|
||||
|
||||
@@ -26,6 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -35,6 +36,9 @@ describe('useEntryActions', () => {
|
||||
const handleUpdateFrontmatter = vi.fn().mockResolvedValue(undefined)
|
||||
const handleDeleteProperty = vi.fn().mockResolvedValue(undefined)
|
||||
const setToastMessage = vi.fn()
|
||||
const createTypeEntry = vi.fn().mockImplementation((typeName: string) =>
|
||||
Promise.resolve(makeEntry({ isA: 'Type', title: typeName, path: `/vault/type/${typeName.toLowerCase()}.md` })),
|
||||
)
|
||||
|
||||
function setup(entries: VaultEntry[] = []) {
|
||||
return renderHook(() =>
|
||||
@@ -44,6 +48,7 @@ describe('useEntryActions', () => {
|
||||
handleUpdateFrontmatter,
|
||||
handleDeleteProperty,
|
||||
setToastMessage,
|
||||
createTypeEntry,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -117,12 +122,12 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
|
||||
describe('handleCustomizeType', () => {
|
||||
it('updates icon and color on the type entry', () => {
|
||||
it('updates icon and color on the type entry', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green')
|
||||
await act(async () => {
|
||||
await result.current.handleCustomizeType('Recipe', 'cooking-pot', 'green')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
|
||||
@@ -130,11 +135,66 @@ describe('useEntryActions', () => {
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
|
||||
})
|
||||
|
||||
it('auto-creates type entry when not found and applies customization', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCustomizeType('Recipe', 'star', 'red')
|
||||
})
|
||||
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('Recipe')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'star', color: 'red' })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'star')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'red')
|
||||
})
|
||||
|
||||
it('serializes frontmatter writes (icon before color)', async () => {
|
||||
const callOrder: string[] = []
|
||||
handleUpdateFrontmatter.mockImplementation((_path: string, key: string) => {
|
||||
callOrder.push(key)
|
||||
return Promise.resolve()
|
||||
})
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCustomizeType('Project', 'wrench', 'blue')
|
||||
})
|
||||
|
||||
expect(callOrder).toEqual(['icon', 'color'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleUpdateTypeTemplate', () => {
|
||||
it('updates template on the type entry', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' })
|
||||
})
|
||||
|
||||
it('sets template to null when empty string', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null })
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', () => {
|
||||
const { result } = setup([])
|
||||
|
||||
act(() => {
|
||||
result.current.handleCustomizeType('NonExistent', 'star', 'red')
|
||||
result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
@@ -178,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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ interface EntryActionsConfig {
|
||||
handleUpdateFrontmatter: (path: string, key: string, value: string | number | boolean | string[]) => Promise<void>
|
||||
handleDeleteProperty: (path: string, key: string) => Promise<void>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
createTypeEntry: (typeName: string) => Promise<VaultEntry>
|
||||
}
|
||||
|
||||
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
@@ -14,7 +15,7 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
|
||||
}
|
||||
|
||||
export function useEntryActions({
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage,
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry,
|
||||
}: EntryActionsConfig) {
|
||||
const handleTrashNote = useCallback(async (path: string) => {
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
@@ -43,13 +44,13 @@ export function useEntryActions({
|
||||
setToastMessage('Note unarchived')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
|
||||
const handleCustomizeType = useCallback((typeName: string, icon: string, color: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
updateEntry(typeEntry.path, { icon, color })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry])
|
||||
|
||||
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
|
||||
for (const { typeName, order } of orderedTypes) {
|
||||
@@ -60,5 +61,24 @@ export function useEntryActions({
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections }
|
||||
const handleUpdateTypeTemplate = useCallback((typeName: string, template: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
handleUpdateFrontmatter(typeEntry.path, 'template', template)
|
||||
updateEntry(typeEntry.path, { template: template || null })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -115,6 +115,48 @@ describe('useNavigationHistory', () => {
|
||||
expect(result.current.canGoForward).toBe(false)
|
||||
})
|
||||
|
||||
it('goBack without predicate returns closed-tab paths (replace scenario)', () => {
|
||||
const { result } = renderHook(() => useNavigationHistory())
|
||||
// Simulate: open A, then B replaces A, then C replaces B
|
||||
act(() => { result.current.push('/a'); result.current.push('/b'); result.current.push('/c') })
|
||||
|
||||
// Without a predicate, goBack returns /b even though its tab was replaced
|
||||
let target: string | null = null
|
||||
act(() => { target = result.current.goBack() })
|
||||
expect(target).toBe('/b')
|
||||
|
||||
act(() => { target = result.current.goBack() })
|
||||
expect(target).toBe('/a')
|
||||
})
|
||||
|
||||
it('goBack with entry-exists predicate skips deleted notes but returns replaced tabs', () => {
|
||||
const { result } = renderHook(() => useNavigationHistory())
|
||||
act(() => { result.current.push('/a'); result.current.push('/deleted'); result.current.push('/c') })
|
||||
|
||||
// Simulate: /deleted was removed from vault, but /a still exists
|
||||
const vaultPaths = new Set(['/a', '/c'])
|
||||
const isEntryExists = (p: string) => vaultPaths.has(p)
|
||||
|
||||
let target: string | null = null
|
||||
act(() => { target = result.current.goBack(isEntryExists) })
|
||||
// Should skip /deleted and return /a
|
||||
expect(target).toBe('/a')
|
||||
})
|
||||
|
||||
it('goForward with entry-exists predicate skips deleted notes', () => {
|
||||
const { result } = renderHook(() => useNavigationHistory())
|
||||
act(() => { result.current.push('/a'); result.current.push('/deleted'); result.current.push('/c') })
|
||||
act(() => { result.current.goBack(); result.current.goBack() })
|
||||
|
||||
const vaultPaths = new Set(['/a', '/c'])
|
||||
const isEntryExists = (p: string) => vaultPaths.has(p)
|
||||
|
||||
let target: string | null = null
|
||||
act(() => { target = result.current.goForward(isEntryExists) })
|
||||
// Should skip /deleted and return /c
|
||||
expect(target).toBe('/c')
|
||||
})
|
||||
|
||||
it('handles long navigation chain', () => {
|
||||
const { result } = renderHook(() => useNavigationHistory())
|
||||
act(() => {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
resolveDailyNote,
|
||||
findDailyNote,
|
||||
useNoteActions,
|
||||
DEFAULT_TEMPLATES,
|
||||
resolveTemplate,
|
||||
} from './useNoteActions'
|
||||
import type { NoteActionsConfig } from './useNoteActions'
|
||||
|
||||
@@ -57,6 +59,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -192,6 +195,47 @@ describe('buildNoteContent', () => {
|
||||
const content = buildNoteContent('AI', 'Topic', null)
|
||||
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n\n# AI\n\n')
|
||||
})
|
||||
|
||||
it('includes template body when provided', () => {
|
||||
const content = buildNoteContent('My Project', 'Project', 'Active', '## Objective\n\n## Notes\n\n')
|
||||
expect(content).toContain('# My Project')
|
||||
expect(content).toContain('## Objective')
|
||||
expect(content).toContain('## Notes')
|
||||
})
|
||||
|
||||
it('ignores null template', () => {
|
||||
const content = buildNoteContent('My Note', 'Note', 'Active', null)
|
||||
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTemplate', () => {
|
||||
it('returns template from type entry when set', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
|
||||
expect(resolveTemplate([typeEntry], 'Recipe')).toBe('## Ingredients\n\n## Steps\n\n')
|
||||
})
|
||||
|
||||
it('falls back to DEFAULT_TEMPLATES for built-in types', () => {
|
||||
expect(resolveTemplate([], 'Project')).toBe(DEFAULT_TEMPLATES.Project)
|
||||
})
|
||||
|
||||
it('returns null when no template and no default', () => {
|
||||
expect(resolveTemplate([], 'CustomType')).toBeNull()
|
||||
})
|
||||
|
||||
it('type entry template overrides default', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom\n\n' })
|
||||
expect(resolveTemplate([typeEntry], 'Project')).toBe('## Custom\n\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_TEMPLATES', () => {
|
||||
it('has templates for Project, Person, Responsibility, Experiment', () => {
|
||||
expect(DEFAULT_TEMPLATES.Project).toBeDefined()
|
||||
expect(DEFAULT_TEMPLATES.Person).toBeDefined()
|
||||
expect(DEFAULT_TEMPLATES.Responsibility).toBeDefined()
|
||||
expect(DEFAULT_TEMPLATES.Experiment).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewNote', () => {
|
||||
@@ -244,6 +288,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['archived', true, { archived: true }],
|
||||
['trashed', true, { trashed: true }],
|
||||
['order', 5, { order: 5 }],
|
||||
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
'maps %s update to correct entry field',
|
||||
(key, value, expected) => {
|
||||
@@ -274,6 +319,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['aliases', { aliases: [] }],
|
||||
['archived', { archived: false }],
|
||||
['order', { order: null }],
|
||||
['template', { template: null }],
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
(key, expected) => {
|
||||
@@ -523,6 +569,43 @@ describe('useNoteActions hook', () => {
|
||||
expect(createdEntry.isA).toBe('Project')
|
||||
})
|
||||
|
||||
it('handleCreateNote uses default template for Project type', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('My Project', 'Project')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Objective')
|
||||
expect(createdContent).toContain('## Key Results')
|
||||
})
|
||||
|
||||
it('handleCreateNote uses custom template from type entry', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', template: '## Ingredients\n\n## Steps\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNote('Pasta', 'Recipe')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Ingredients')
|
||||
expect(createdContent).toContain('## Steps')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate uses template for typed notes', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('Project')
|
||||
})
|
||||
|
||||
const [, createdContent] = addEntry.mock.calls[0]
|
||||
expect(createdContent).toContain('## Custom Template')
|
||||
})
|
||||
|
||||
it('handleUpdateFrontmatter does not call updateEntry for unknown keys', async () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,11 +129,26 @@ const TYPE_FOLDER_MAP: Record<string, string> = {
|
||||
|
||||
const NO_STATUS_TYPES = new Set(['Topic', 'Person', 'Journal'])
|
||||
|
||||
/** Default templates for built-in types. Used when the type entry has no custom template. */
|
||||
export const DEFAULT_TEMPLATES: Record<string, string> = {
|
||||
Project: '## Objective\n\n\n\n## Key Results\n\n\n\n## Notes\n\n',
|
||||
Person: '## Role\n\n\n\n## Contact\n\n\n\n## Notes\n\n',
|
||||
Responsibility: '## Description\n\n\n\n## Key Activities\n\n\n\n## Notes\n\n',
|
||||
Experiment: '## Hypothesis\n\n\n\n## Method\n\n\n\n## Results\n\n\n\n## Conclusion\n\n',
|
||||
}
|
||||
|
||||
/** Look up the template for a given type from the type entry or defaults. */
|
||||
export function resolveTemplate(entries: VaultEntry[], typeName: string): string | null {
|
||||
const typeEntry = entries.find(e => e.isA === 'Type' && e.title === typeName)
|
||||
return typeEntry?.template ?? DEFAULT_TEMPLATES[typeName] ?? null
|
||||
}
|
||||
|
||||
const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
|
||||
template: { template: null },
|
||||
}
|
||||
|
||||
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
|
||||
@@ -150,6 +165,7 @@ export function frontmatterToEntryPatch(
|
||||
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
|
||||
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
|
||||
order: { order: typeof value === 'number' ? value : null },
|
||||
template: { template: str },
|
||||
}
|
||||
return updates[k] ?? {}
|
||||
}
|
||||
@@ -159,19 +175,20 @@ function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: Vaul
|
||||
addEntry(entry, content)
|
||||
}
|
||||
|
||||
export function buildNoteContent(title: string, type: string, status: string | null): string {
|
||||
export function buildNoteContent(title: string, type: string, status: string | null, template?: string | null): string {
|
||||
const lines = ['---', `title: ${title}`, `type: ${type}`]
|
||||
if (status) lines.push(`status: ${status}`)
|
||||
lines.push('---')
|
||||
return `${lines.join('\n')}\n\n# ${title}\n\n`
|
||||
const body = template ? `\n${template}` : '\n'
|
||||
return `${lines.join('\n')}\n\n# ${title}\n${body}`
|
||||
}
|
||||
|
||||
export function resolveNewNote(title: string, type: string): { entry: VaultEntry; content: string } {
|
||||
export function resolveNewNote(title: string, type: string, template?: string | null): { entry: VaultEntry; content: string } {
|
||||
const folder = TYPE_FOLDER_MAP[type] || slugify(type)
|
||||
const slug = slugify(title)
|
||||
const status = NO_STATUS_TYPES.has(type) ? null : 'Active'
|
||||
const entry = buildNewEntry({ path: `/Users/luca/Laputa/${folder}/${slug}.md`, slug, title, type, status })
|
||||
return { entry, content: buildNoteContent(title, type, status) }
|
||||
return { entry, content: buildNoteContent(title, type, status, template) }
|
||||
}
|
||||
|
||||
export function resolveNewType(typeName: string): { entry: VaultEntry; content: string } {
|
||||
@@ -224,8 +241,10 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e:
|
||||
}
|
||||
|
||||
/** Dispatch focus-editor event with perf timing marker. */
|
||||
function signalFocusEditor(): void {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { t0: performance.now() } }))
|
||||
function signalFocusEditor(opts?: { selectTitle?: boolean }): void {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', {
|
||||
detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false },
|
||||
}))
|
||||
}
|
||||
|
||||
interface PersistCallbacks {
|
||||
@@ -340,18 +359,22 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
[openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave], // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
|
||||
)
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string) => persistNew(resolveNewNote(title, type)), [persistNew])
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate(entries, type)
|
||||
persistNew(resolveNewNote(title, type, template))
|
||||
}, [entries, persistNew])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const resolved = resolveNewNote(title, noteType)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor()
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
}, [entries, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
@@ -368,6 +391,14 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName)), [persistNew])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
const resolved = resolveNewType(typeName)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
await persistNewNote(resolved.entry.path, resolved.content)
|
||||
return resolved.entry
|
||||
}, [addEntry])
|
||||
|
||||
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }
|
||||
|
||||
const runFrontmatterOp = useCallback(
|
||||
@@ -405,6 +436,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
handleCreateNoteImmediate,
|
||||
handleOpenDailyNote,
|
||||
handleCreateType,
|
||||
createTypeEntrySilent,
|
||||
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
|
||||
@@ -22,6 +22,7 @@ function makeEntry(path: string, title: string): VaultEntry {
|
||||
fileSize: 100,
|
||||
color: null,
|
||||
icon: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
relationships: {},
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock('../mock-tauri', () => ({
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./useVaultSwitcher', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
@@ -35,7 +38,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 +53,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 +63,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 +83,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 +105,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 +133,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 +155,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 +176,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
|
||||
})
|
||||
|
||||
87
src/hooks/useRawMode.test.ts
Normal file
87
src/hooks/useRawMode.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useRawMode } from './useRawMode'
|
||||
|
||||
describe('useRawMode', () => {
|
||||
let onFlushPending: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
onFlushPending = vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
|
||||
function renderRawHook(activeTabPath: string | null = '/note.md') {
|
||||
return renderHook(
|
||||
({ path }) => useRawMode({ activeTabPath: path, onFlushPending }),
|
||||
{ initialProps: { path: activeTabPath } },
|
||||
)
|
||||
}
|
||||
|
||||
it('starts with raw mode off', () => {
|
||||
const { result } = renderRawHook()
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles raw mode on', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes pending edits when activating raw mode', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onFlushPending).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not flush pending edits when deactivating raw mode', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
onFlushPending.mockClear()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onFlushPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles raw mode off when already on', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('resets raw mode when activeTabPath changes', async () => {
|
||||
const { result, rerender } = renderRawHook('/note-a.md')
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
|
||||
rerender({ path: '/note-b.md' })
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('works without onFlushPending callback', async () => {
|
||||
const { result } = renderHook(() => useRawMode({ activeTabPath: '/note.md' }))
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
})
|
||||
|
||||
it('does not activate raw mode when activeTabPath is null', async () => {
|
||||
const { result } = renderRawHook(null)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
// Cannot activate raw mode without an active tab path
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
})
|
||||
29
src/hooks/useRawMode.ts
Normal file
29
src/hooks/useRawMode.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
interface UseRawModeParams {
|
||||
activeTabPath: string | null
|
||||
/** Flush pending WYSIWYG edits to disk before entering raw mode. */
|
||||
onFlushPending?: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages raw editor mode state.
|
||||
* Raw mode is automatically inactive when the active tab changes,
|
||||
* because rawMode is derived from whether the stored path matches the current tab.
|
||||
*/
|
||||
export function useRawMode({ activeTabPath, onFlushPending }: UseRawModeParams) {
|
||||
// Track which path has raw mode active — automatically deactivates on tab switch
|
||||
const [rawActivePath, setRawActivePath] = useState<string | null>(null)
|
||||
const rawMode = rawActivePath !== null && rawActivePath === activeTabPath
|
||||
|
||||
const handleToggleRaw = useCallback(async () => {
|
||||
if (rawMode) {
|
||||
setRawActivePath(null)
|
||||
} else {
|
||||
await onFlushPending?.()
|
||||
setRawActivePath(activeTabPath)
|
||||
}
|
||||
}, [rawMode, activeTabPath, onFlushPending])
|
||||
|
||||
return { rawMode, handleToggleRaw }
|
||||
}
|
||||
@@ -32,6 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -1,246 +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 } = await import('./useThemeManager')
|
||||
const { useThemeManager, extractCssVars } = await import('./useThemeManager')
|
||||
|
||||
describe('useThemeManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'list_themes') return mockThemes
|
||||
if (cmd === 'get_vault_settings') return mockSettings
|
||||
if (cmd === 'set_active_theme') return null
|
||||
if (cmd === 'create_theme') return 'new-theme-id'
|
||||
return null
|
||||
})
|
||||
// Clear any theme CSS properties from previous tests
|
||||
const root = document.documentElement
|
||||
root.style.cssText = ''
|
||||
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('loads themes and active theme on mount', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
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, 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_vault_theme') return '/vault/theme/untitled.md'
|
||||
return null
|
||||
})
|
||||
document.documentElement.style.cssText = ''
|
||||
})
|
||||
|
||||
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('reloadThemes re-fetches theme list', 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.themes).toHaveLength(2)
|
||||
expect(result.current.activeTheme).not.toBeNull()
|
||||
})
|
||||
|
||||
const initialListCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'list_themes').length
|
||||
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('reloadThemes re-reads vault settings', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
)
|
||||
await waitFor(() => { expect(result.current.themes).toHaveLength(2) })
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,124 +1,203 @@
|
||||
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)
|
||||
}
|
||||
|
||||
/** Map theme colors/typography/spacing to CSS custom properties on :root. */
|
||||
function applyThemeToDom(theme: ThemeFile): void {
|
||||
/** 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',
|
||||
])
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
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(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'])
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
themes: ThemeFile[]
|
||||
activeThemeId: string | null
|
||||
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 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, 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 }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
}))
|
||||
|
||||
// Mock openExternalUrl
|
||||
const mockOpenExternalUrl = vi.fn()
|
||||
vi.mock('../utils/url', () => ({
|
||||
openExternalUrl: (...args: unknown[]) => mockOpenExternalUrl(...args),
|
||||
}))
|
||||
|
||||
// Mock the dynamic imports
|
||||
const mockCheck = vi.fn()
|
||||
const mockRelaunch = vi.fn()
|
||||
@@ -149,9 +155,8 @@ describe('useUpdater', () => {
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
it('openReleaseNotes opens the release notes URL', async () => {
|
||||
it('openReleaseNotes opens the release notes URL via openExternalUrl', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
const { result } = renderHook(() => useUpdater())
|
||||
|
||||
@@ -159,9 +164,8 @@ describe('useUpdater', () => {
|
||||
result.current.actions.openReleaseNotes()
|
||||
})
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/',
|
||||
'_blank'
|
||||
expect(mockOpenExternalUrl).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -195,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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/'
|
||||
|
||||
@@ -10,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
|
||||
@@ -20,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 {
|
||||
@@ -80,14 +85,14 @@ export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
}, [])
|
||||
|
||||
const openReleaseNotes = useCallback(() => {
|
||||
window.open(RELEASE_NOTES_URL, '_blank')
|
||||
openExternalUrl(RELEASE_NOTES_URL)
|
||||
}, [])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setStatus({ state: 'idle' })
|
||||
}, [])
|
||||
|
||||
return { status, actions: { startDownload, openReleaseNotes, dismiss } }
|
||||
return { status, actions: { checkForUpdates, startDownload, openReleaseNotes, dismiss } }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ const mockEntries: VaultEntry[] = [
|
||||
status: 'Active', owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 100,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
190
src/hooks/useVaultSwitcher.test.ts
Normal file
190
src/hooks/useVaultSwitcher.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { useVaultSwitcher, DEFAULT_VAULTS } from './useVaultSwitcher'
|
||||
import type { PersistedVaultList } from './useVaultSwitcher'
|
||||
|
||||
let mockVaultListStore: PersistedVaultList = { vaults: [], active_vault: null }
|
||||
|
||||
const mockInvokeFn = vi.fn((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(true)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
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),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useVaultSwitcher', () => {
|
||||
const onSwitch = vi.fn()
|
||||
const onToast = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockVaultListStore = { vaults: [], active_vault: null }
|
||||
// Re-set default implementation after resetAllMocks
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(true)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
})
|
||||
|
||||
it('starts with default vaults', () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
|
||||
expect(result.current.vaultPath).toBe(DEFAULT_VAULTS[0].path)
|
||||
})
|
||||
|
||||
it('loads persisted vaults on mount', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'My Vault', path: '/Users/luca/Laputa' }],
|
||||
active_vault: '/Users/luca/Laputa',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loaded).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.allVaults).toHaveLength(2) // default + persisted
|
||||
expect(result.current.allVaults[1].label).toBe('My Vault')
|
||||
expect(result.current.allVaults[1].path).toBe('/Users/luca/Laputa')
|
||||
expect(result.current.allVaults[1].available).toBe(true)
|
||||
expect(result.current.vaultPath).toBe('/Users/luca/Laputa')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('load_vault_list', {})
|
||||
})
|
||||
|
||||
it('marks unavailable vaults when check_vault_exists returns false', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'External', path: '/Volumes/USB/vault' }],
|
||||
active_vault: null,
|
||||
}
|
||||
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
|
||||
if (cmd === 'save_vault_list') {
|
||||
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (cmd === 'check_vault_exists') return Promise.resolve(false)
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loaded).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.allVaults[1].available).toBe(false)
|
||||
expect(result.current.allVaults[1].label).toBe('External')
|
||||
})
|
||||
|
||||
it('persists vault list when adding a vault via handleVaultCloned', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/cloned/vault', 'Cloned')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
|
||||
list: expect.objectContaining({
|
||||
vaults: expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'Cloned', path: '/cloned/vault' }),
|
||||
]),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('persists active vault when switching', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: null,
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.switchVault('/work/vault')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', expect.objectContaining({
|
||||
list: expect.objectContaining({
|
||||
active_vault: '/work/vault',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
expect(onSwitch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles load error gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'load_vault_list') return Promise.reject(new Error('disk error'))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
// Should fall back to defaults
|
||||
expect(result.current.allVaults).toEqual(DEFAULT_VAULTS)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not duplicate vaults with same path', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/some/vault', 'First')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleVaultCloned('/some/vault', 'Duplicate')
|
||||
})
|
||||
|
||||
const extras = result.current.allVaults.filter(v => v.path === '/some/vault')
|
||||
expect(extras).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens local folder and persists', async () => {
|
||||
const { pickFolder } = await import('../utils/vault-dialog')
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/MyVault')
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleOpenLocalFolder()
|
||||
})
|
||||
|
||||
expect(result.current.allVaults.some(v => v.path === '/Users/luca/MyVault')).toBe(true)
|
||||
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
|
||||
})
|
||||
})
|
||||
@@ -1,36 +1,68 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
|
||||
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 type { PersistedVaultList } from '../utils/vaultListStore'
|
||||
|
||||
export const DEFAULT_VAULTS: VaultOption[] = [
|
||||
{ label: 'Getting Started', path: '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
]
|
||||
|
||||
interface UseVaultSwitcherOptions {
|
||||
onSwitch: () => void
|
||||
onToast: (msg: string) => void
|
||||
}
|
||||
|
||||
/** Manages vault path, extra vaults, switching, cloning, and local folder opening. */
|
||||
function labelFromPath(path: string): string {
|
||||
return path.split('/').pop() || 'Local Vault'
|
||||
}
|
||||
|
||||
/** Manages vault path, extra vaults, switching, cloning, and local folder opening.
|
||||
* Vault list and active vault are persisted via Tauri backend to survive app updates. */
|
||||
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
|
||||
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
|
||||
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const allVaults = useMemo(() => [...DEFAULT_VAULTS, ...extraVaults], [extraVaults])
|
||||
|
||||
// Refs ensure stable callbacks that always invoke the latest closures,
|
||||
// breaking the circular dependency between useVaultSwitcher and downstream hooks.
|
||||
const onSwitchRef = useRef(onSwitch)
|
||||
const onToastRef = useRef(onToast)
|
||||
useEffect(() => { onSwitchRef.current = onSwitch; onToastRef.current = onToast })
|
||||
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadVaultList()
|
||||
.then(({ vaults, activeVault }) => {
|
||||
if (cancelled) return
|
||||
setExtraVaults(vaults)
|
||||
if (activeVault) {
|
||||
setVaultPath(activeVault)
|
||||
onSwitchRef.current()
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('Failed to load vault list:', err))
|
||||
.finally(() => {
|
||||
hasLoadedRef.current = true
|
||||
setLoaded(true)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasLoadedRef.current) return
|
||||
saveVaultList(extraVaults, vaultPath).catch(err =>
|
||||
console.warn('Failed to persist vault list:', err),
|
||||
)
|
||||
}, [extraVaults, vaultPath])
|
||||
|
||||
const addVault = useCallback((path: string, label: string) => {
|
||||
setExtraVaults(prev => prev.some(v => v.path === path) ? prev : [...prev, { label, path }])
|
||||
setExtraVaults(prev => {
|
||||
const exists = prev.some(v => v.path === path)
|
||||
return exists ? prev : [...prev, { label, path, available: true }]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const switchVault = useCallback((path: string) => {
|
||||
@@ -38,25 +70,23 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
onSwitchRef.current()
|
||||
}, [])
|
||||
|
||||
const handleVaultCloned = useCallback((path: string, label: string) => {
|
||||
const addAndSwitch = useCallback((path: string, label: string) => {
|
||||
addVault(path, label)
|
||||
switchVault(path)
|
||||
onToastRef.current(`Vault "${label}" cloned and opened`)
|
||||
}, [addVault, switchVault])
|
||||
|
||||
const handleVaultCloned = useCallback((path: string, label: string) => {
|
||||
addAndSwitch(path, label)
|
||||
onToastRef.current(`Vault "${label}" cloned and opened`)
|
||||
}, [addAndSwitch])
|
||||
|
||||
const handleOpenLocalFolder = useCallback(async () => {
|
||||
try {
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
const label = path.split('/').pop() || 'Local Vault'
|
||||
addVault(path, label)
|
||||
switchVault(path)
|
||||
onToastRef.current(`Vault "${label}" opened`)
|
||||
} catch (err) {
|
||||
console.error('Failed to open local folder:', err)
|
||||
onToastRef.current(`Failed to open folder: ${err}`)
|
||||
}
|
||||
}, [addVault, switchVault])
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
const label = labelFromPath(path)
|
||||
addAndSwitch(path, label)
|
||||
onToastRef.current(`Vault "${label}" opened`)
|
||||
}, [addAndSwitch])
|
||||
|
||||
return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder }
|
||||
return { vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded }
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Force light mode — dark mode removed for now
|
||||
document.documentElement.classList.remove('dark')
|
||||
|
||||
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
|
||||
// at native level before React's synthetic events can call preventDefault).
|
||||
// Capture phase fires first → prevents native menu; React bubble phase still fires
|
||||
|
||||
@@ -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.
|
||||
`,
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
|
||||
},
|
||||
{
|
||||
@@ -71,6 +72,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
|
||||
},
|
||||
{
|
||||
@@ -100,6 +102,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['person/matteo-cellini'],
|
||||
},
|
||||
{
|
||||
@@ -129,6 +132,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
{
|
||||
@@ -158,6 +162,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/manage-sponsorships'],
|
||||
},
|
||||
{
|
||||
@@ -188,6 +193,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
|
||||
},
|
||||
{
|
||||
@@ -218,6 +224,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
|
||||
},
|
||||
{
|
||||
@@ -247,6 +254,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app'],
|
||||
},
|
||||
{
|
||||
@@ -275,6 +283,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -303,6 +312,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -331,6 +341,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -359,6 +370,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -388,6 +400,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
|
||||
},
|
||||
{
|
||||
@@ -417,6 +430,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -446,6 +460,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -475,6 +490,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
{
|
||||
@@ -505,6 +521,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
|
||||
},
|
||||
{
|
||||
@@ -534,6 +551,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: ['responsibility/grow-newsletter'],
|
||||
},
|
||||
// --- Type documents ---
|
||||
@@ -561,6 +579,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 0,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -587,6 +606,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 1,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -613,6 +633,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 2,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -639,6 +660,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 3,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -665,6 +687,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 4,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -691,6 +714,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 5,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -717,6 +741,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 6,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -743,6 +768,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 7,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -769,6 +795,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: 8,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Custom type documents ---
|
||||
@@ -796,6 +823,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: 'orange',
|
||||
order: 9,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -822,6 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: 'green',
|
||||
order: 10,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Instances of custom types ---
|
||||
@@ -851,6 +880,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -879,6 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Trashed entries ---
|
||||
@@ -909,6 +940,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -937,6 +969,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
{
|
||||
@@ -966,6 +999,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
},
|
||||
// --- Archived entries ---
|
||||
@@ -987,6 +1021,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
modifiedAt: now - 86400 * 120,
|
||||
createdAt: now - 86400 * 200,
|
||||
@@ -1016,6 +1051,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
modifiedAt: now - 86400 * 90,
|
||||
createdAt: now - 86400 * 150,
|
||||
@@ -1079,10 +1115,96 @@ function generateBulkEntries(count: number): VaultEntry[] {
|
||||
order: null,
|
||||
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
})
|
||||
}
|
||||
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))
|
||||
|
||||
@@ -82,8 +82,15 @@ let mockSettings: Settings = {
|
||||
auto_pull_interval_minutes: 5,
|
||||
}
|
||||
|
||||
let mockLastVaultPath: string | null = null
|
||||
|
||||
let mockVaultSettings: VaultSettings = { theme: null }
|
||||
|
||||
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
}
|
||||
|
||||
const mockThemes: ThemeFile[] = [
|
||||
{
|
||||
id: 'default', name: 'Default', description: 'Light theme with warm, paper-like tones',
|
||||
@@ -197,6 +204,8 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
}
|
||||
return null
|
||||
},
|
||||
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
|
||||
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
|
||||
rename_note: handleRenameNote,
|
||||
github_list_repos: () => [
|
||||
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
|
||||
@@ -245,12 +254,14 @@ 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_last_vault_path: () => mockLastVaultPath,
|
||||
set_last_vault_path: (args: { path: string }) => { mockLastVaultPath = args.path; return null },
|
||||
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 +279,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 {
|
||||
|
||||
@@ -5,6 +5,13 @@ import { createElement, type ReactNode, type ComponentType } from 'react'
|
||||
// Mock scrollIntoView for jsdom (not implemented)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
// Mock ResizeObserver for jsdom (not implemented)
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver
|
||||
|
||||
// Mock @tauri-apps/plugin-opener for test environment
|
||||
vi.mock('@tauri-apps/plugin-opener', () => ({
|
||||
openUrl: vi.fn(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface VaultEntry {
|
||||
order: number | null
|
||||
/** Custom sidebar section label for Type entries, overriding auto-pluralization. */
|
||||
sidebarLabel: string | null
|
||||
/** Markdown template for Type entries. Pre-fills new notes created with this type. */
|
||||
template: string | null
|
||||
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
|
||||
outgoingLinks: string[]
|
||||
}
|
||||
@@ -118,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>
|
||||
|
||||
@@ -10,7 +10,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, outgoingLinks: [],
|
||||
icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
21
src/utils/rawEditorUtils.ts
Normal file
21
src/utils/rawEditorUtils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** Extract the wikilink query that the user is currently typing after [[ */
|
||||
export function extractWikilinkQuery(text: string, cursor: number): string | null {
|
||||
const before = text.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return null
|
||||
const afterTrigger = before.slice(triggerIdx + 2)
|
||||
// Don't trigger if the query contains ] (already closed) or a newline
|
||||
if (afterTrigger.includes(']') || afterTrigger.includes('\n')) return null
|
||||
return afterTrigger
|
||||
}
|
||||
|
||||
/** Basic YAML frontmatter structural checks. */
|
||||
export function detectYamlError(content: string): string | null {
|
||||
if (!content.startsWith('---')) return null
|
||||
const rest = content.slice(3)
|
||||
const closeIdx = rest.search(/\n---(\n|$)/)
|
||||
if (closeIdx === -1) return 'Unclosed frontmatter block — add a closing --- line'
|
||||
const block = rest.slice(0, closeIdx)
|
||||
if (/^\t/m.test(block)) return 'YAML frontmatter contains tab indentation — use spaces'
|
||||
return null
|
||||
}
|
||||
@@ -13,7 +13,7 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
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, outgoingLinks: [],
|
||||
order: null, template: null, outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
5
src/utils/tabLayout.ts
Normal file
5
src/utils/tabLayout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** Compute per-tab max-width so all tabs fit within the container. */
|
||||
export function computeTabMaxWidth(containerWidth: number, tabCount: number): number {
|
||||
if (tabCount === 0) return 360
|
||||
return Math.max(60, Math.min(360, Math.floor(containerWidth / tabCount)))
|
||||
}
|
||||
@@ -54,7 +54,7 @@ const baseEntry: VaultEntry = {
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', relationships: {},
|
||||
wordCount: 0,
|
||||
icon: null, color: null, order: null, outgoingLinks: [],
|
||||
icon: null, color: null, order: null, template: null, outgoingLinks: [],
|
||||
}
|
||||
|
||||
describe('buildTypeEntryMap', () => {
|
||||
|
||||
36
src/utils/vaultListStore.ts
Normal file
36
src/utils/vaultListStore.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultOption } from '../components/StatusBar'
|
||||
|
||||
export interface PersistedVaultList {
|
||||
vaults: Array<{ label: string; path: string }>
|
||||
active_vault: string | null
|
||||
}
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
async function checkAvailability(v: { label: string; path: string }): Promise<VaultOption> {
|
||||
try {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: v.path })
|
||||
return { label: v.label, path: v.path, available: exists }
|
||||
} catch {
|
||||
return { label: v.label, path: v.path, available: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVault: string | null }> {
|
||||
const data = await tauriCall<PersistedVaultList>('load_vault_list', {})
|
||||
const persisted = data?.vaults ?? []
|
||||
const checked = await Promise.all(persisted.map(checkAvailability))
|
||||
return { vaults: checked, activeVault: data?.active_vault ?? null }
|
||||
}
|
||||
|
||||
export function saveVaultList(vaults: VaultOption[], activeVault: string): Promise<void> {
|
||||
const list: PersistedVaultList = {
|
||||
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
|
||||
active_vault: activeVault,
|
||||
}
|
||||
return tauriCall('save_vault_list', { list })
|
||||
}
|
||||
@@ -26,6 +26,7 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ export default defineConfig({
|
||||
|
||||
// Tauri expects a fixed port
|
||||
server: {
|
||||
port: 5201,
|
||||
port: 5202,
|
||||
strictPort: true,
|
||||
allowedHosts: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user