Compare commits

...

18 Commits

Author SHA1 Message Date
Test
17eeac75cd fix: open newly created theme in editor after New Theme command
After createTheme(), the theme file was created and the sidebar navigated
to the Theme section, but the note was never opened in the editor.
Now captures the returned path and opens it via handleSelectNote.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:29:32 +01:00
Test
2dad764ea7 fix: check-for-updates command always visible in Cmd+K, handles all update states 2026-03-03 16:46:47 +01:00
Test
c3fa296b99 refactor: remove AI model indicator from status bar
Remove the hardcoded "Claude Sonnet 4" stub label and unused Sparkles
import — no model picker is planned at this stage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:57:20 +01:00
Test
6370b66e05 fix: handle Escape at panel level and manage focus across active states
Move Escape handler from input onKeyDown to a window-level listener
scoped to the panel, so it works even when input is disabled during
AI response. When agent is active, focus transfers to the panel
container; when idle, focus returns to the input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:34:18 +01:00
Test
1ec27dd264 fix: auto-focus AI Chat input on panel open and close on Escape
When AI Chat panel mounts (via Cmd+I), the input field now receives
focus automatically so users can type immediately without clicking.
Pressing Escape in the input closes the panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:30:12 +01:00
Test
10e6d7b366 fix: handle EADDRINUSE in MCP server ws-bridge — allow Claude Code to start when port is taken
When Claude CLI starts the Laputa MCP server, it crashed immediately because
startUiBridge() tried to bind port 9711 which is already held by the running
Laputa app. The unhandled EADDRINUSE error killed the process, making all
Laputa MCP tools unavailable to Claude Code in AI Chat.

Fix:
- Make startUiBridge() async, return Promise<WebSocketServer|null>
- Handle 'error' event on HTTP server: EADDRINUSE resolves to null instead of crashing
- Guard broadcastUiAction() with 'if (!uiBridge) return' for graceful no-op
- In ws-bridge.js main: chain startUiBridge().then(() => startBridge())

All vault tools (read/write/search) now work via stdio MCP when port is busy.
2026-03-03 13:22:28 +01:00
Test
0c87e51037 feat: add Check for Updates command to native menu and Cmd+K palette
- Add APP_CHECK_FOR_UPDATES constant and menu item in menu.rs (between About and Settings)
- Wire onCheckForUpdates handler through useMenuEvents and useAppCommands
- Add test for app-check-for-updates dispatch
2026-03-03 13:19:09 +01:00
Test
7df1961172 feat: persist note list sort preference in type file frontmatter
Sort preferences for each type's note list are now stored in the type
file's frontmatter (e.g. `sort: modified:desc` in `type/person.md`)
instead of localStorage. This makes preferences portable with the vault
and versionable in git.

- Add `sort` field to Rust Frontmatter/VaultEntry and TS VaultEntry
- NoteList reads sort from type entry's frontmatter when viewing a type
- Sort changes write to type file via update_frontmatter
- Silent migration from localStorage on first access per type
- Relationship group sorts still use localStorage (no type file)
- Fallback to `modified:desc` when no sort preference exists

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 12:18:40 +01:00
Test
ec74f86d53 fix: restore theming system after dark-editor merge regression
The merge of the dark-theme-editor feature (cadb350) into the
themes-editable rewrite (19bc3c6) broke the entire theming UI:
themes weren't listed, switching didn't work, and new theme creation
was broken.

Root causes and fixes:
- Stale theme ID from old JSON system ("untitled-2") never cleared:
  added detection that clears IDs not matching any known vault theme,
  with a ref to skip IDs just set by switchTheme/createTheme
- set_active_theme Rust command only accepted String, not Option:
  now accepts Option<String> so null can clear the setting
- Theme colors empty in UI: entryToThemeFile now extracts colors
  from frontmatter content via extractColorsFromContent
- color-scheme/data-theme-mode not set: added updateColorScheme
  and clearColorScheme to sync DOM attributes on theme apply/clear
- isDark broken in Tauri (allContent is {}): moved isDark tracking
  into useThemeApplier as state, updated when vars are applied
- SettingsPanel passed activeThemeId as name to createTheme: fixed
  to call createTheme() with no arguments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:53:00 +01:00
Luca Rossi
d86dfbfcb6 refactor: extract useEditorSaveWithLinks and useNavigationGestures from App.tsx (#186)
App.tsx had 539 lines and 112 git touches in 30 days — highest churn in
the codebase. Two self-contained hooks were defined inline with no
dependency on App internals:

- useEditorSaveWithLinks: wraps useEditorSave to also extract and update
  outgoing wikilinks on save. Moved to src/hooks/useEditorSaveWithLinks.ts.

- useNavigationGestures: registers mouse button 3/4 back/forward and
  macOS trackpad horizontal swipe listeners. Moved to
  src/hooks/useNavigationGestures.ts.

App.tsx shrinks from 539 → 473 lines (-66 lines). Both hooks are now
independently testable and reusable.

Co-authored-by: Test <test@test.com>
2026-03-03 07:10:34 +01:00
Test
e77208ec34 style: cargo fmt git.rs 2026-03-03 02:49:25 +01:00
Test
818707603e fix: add useIndexing import and hook call, wire indexingProgress and onRemoveVault to StatusBar 2026-03-03 02:48:10 +01:00
Test
fa3f9adccc chore: add sync-conflict-resolution design file
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:45:42 +01:00
Test
bf6312000e feat: add sync conflict resolution — resolve merge conflicts in-app
- Add git_resolve_conflict and git_commit_conflict_resolution Rust commands
- Create useConflictResolver hook for per-file resolution state management
- Create ConflictResolverModal with keyboard shortcuts (K/T/O/Enter/Esc)
- Make StatusBar conflict chip clickable to open resolver modal
- Add 'Resolve Conflicts' command to command palette (conditional)
- Pause auto-pull while conflict resolver modal is open
- Tests: 5 new Rust tests, 10 new hook tests, 1 new auto-sync test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:45:42 +01:00
Test
832181b9a9 style: cargo fmt --all 2026-03-03 02:44:00 +01:00
Test
bc011692cd style: rustfmt lib.rs 2026-03-03 02:43:15 +01:00
Test
0604a13cdd fix: add missing X import and onRemoveVault prop to StatusBar after rebase 2026-03-03 02:38:44 +01:00
Test
eb77607401 feat: auto-index vault on open + qmd auto-install + status bar progress
Search now works out of the box on any vault without manual qmd
installation. On vault open, the app checks the index status and
auto-triggers background indexing (qmd update + embed) when needed.
If qmd is not installed, it auto-installs via bun.

Changes:
- New indexing.rs module: find_qmd_binary (cached), check_index_status,
  ensure_collection, run_full_index with progress callbacks,
  run_incremental_update, auto_install_qmd
- search.rs: delegates to indexing::find_qmd_binary (removes duplication)
- lib.rs: new Tauri commands (get_index_status, start_indexing,
  trigger_incremental_index)
- useIndexing hook: auto-triggers on vault open, listens for
  indexing-progress events, auto-dismisses complete state after 5s
- StatusBar: IndexingBadge shows phase + progress counts with spinner
- App.tsx: wires up useIndexing, triggers incremental index on file save
- design/search-bundle-qmd.pen: documents indexing progress UI states

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:37:49 +01:00
62 changed files with 2515 additions and 348 deletions

View File

@@ -0,0 +1 @@
{"children":[{"id":"status-bar-indexing","type":"frame","name":"StatusBar - Indexing Progress States","layout":"vertical","x":0,"y":0,"width":1400,"height":400,"gap":24,"padding":24,"fill":"#F7F6F3","children":[{"id":"title","type":"text","content":"StatusBar Indexing Progress - search-bundle-qmd","fontSize":20,"fontWeight":"600","fill":"#37352F"},{"id":"desc","type":"text","content":"The indexing badge appears in the StatusBar left section, after the pending changes badge. It shows the current indexing phase with a spinner icon (Loader2) during active phases and a Search icon when complete.","fontSize":14,"fill":"#9B9A97","width":900},{"id":"states","type":"frame","name":"States","layout":"vertical","gap":16,"width":1350,"children":[{"id":"state-idle","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-idle","type":"text","content":"idle:","fontSize":12,"fontWeight":"600","fill":"#9B9A97","width":100},{"id":"val-idle","type":"text","content":"(hidden - no badge shown)","fontSize":12,"fill":"#9B9A97"}]},{"id":"state-installing","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-install","type":"text","content":"installing:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-install","type":"text","content":"| [spinner] Installing search\u2026","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-scanning","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-scan","type":"text","content":"scanning:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-scan","type":"text","content":"| [spinner] Indexing\u2026 342/1,057","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-embedding","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-embed","type":"text","content":"embedding:","fontSize":12,"fontWeight":"600","fill":"#3b82f6","width":100},{"id":"val-embed","type":"text","content":"| [spinner] Embedding\u2026 50/200","fontSize":12,"fill":"#3b82f6"}]},{"id":"state-complete","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-done","type":"text","content":"complete:","fontSize":12,"fontWeight":"600","fill":"#22c55e","width":100},{"id":"val-done","type":"text","content":"| [search icon] Index ready (auto-dismisses after 5s)","fontSize":12,"fill":"#22c55e"}]},{"id":"state-error","type":"frame","layout":"horizontal","gap":8,"alignItems":"center","children":[{"id":"label-err","type":"text","content":"error:","fontSize":12,"fontWeight":"600","fill":"#f97316","width":100},{"id":"val-err","type":"text","content":"| [search icon] Index error (orange accent)","fontSize":12,"fill":"#f97316"}]}]},{"id":"flow","type":"frame","name":"Flow","layout":"vertical","gap":8,"width":1350,"children":[{"id":"flow-title","type":"text","content":"Auto-indexing Flow","fontSize":16,"fontWeight":"600","fill":"#37352F"},{"id":"flow-desc","type":"text","content":"1. Vault opens \u2192 useIndexing checks index status via get_index_status\n2. If qmd missing or collection missing or pending embeds \u2192 triggers start_indexing\n3. Rust emits indexing-progress events (installing \u2192 scanning \u2192 embedding \u2192 complete)\n4. StatusBar shows IndexingBadge with spinner + phase label + progress counts\n5. On file save \u2192 trigger_incremental_index runs qmd update in background\n6. Complete phase auto-dismisses after 5 seconds","fontSize":13,"fill":"#37352F","width":900}]}]}],"variables":{}}

View File

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

View File

@@ -32,10 +32,16 @@ import { startUiBridge } from './ws-bridge.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
const uiBridge = startUiBridge(WS_UI_PORT)
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions.
// If the port is already in use (e.g. by the running Laputa app), continue
// without the bridge — vault tools still work via stdio MCP.
let uiBridge = null
startUiBridge(WS_UI_PORT).then((bridge) => {
uiBridge = bridge
})
function broadcastUiAction(action, payload) {
if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)

View File

@@ -19,6 +19,7 @@
* Protocol (UI bridge):
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
*/
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import {
readNote, createNote, searchNotes, appendToNote,
@@ -80,15 +81,34 @@ async function handleMessage(data) {
}
}
/**
* Attempt to start the UI bridge WebSocket server.
* Returns a Promise that resolves to the WebSocketServer or null if the port
* is unavailable (e.g. another Laputa instance owns it).
*/
export function startUiBridge(port = WS_UI_PORT) {
uiBridge = new WebSocketServer({ port })
return new Promise((resolve) => {
const httpServer = createServer()
uiBridge.on('connection', () => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
httpServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`[ws-bridge] UI bridge port ${port} already in use, disabling bridge`)
} else {
console.error(`[ws-bridge] UI bridge error: ${err.message}`)
}
resolve(null)
})
httpServer.listen(port, () => {
const wss = new WebSocketServer({ server: httpServer })
wss.on('connection', () => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
})
uiBridge = wss
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
resolve(wss)
})
})
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
return uiBridge
}
export function startBridge(port = WS_PORT) {
@@ -116,6 +136,5 @@ export function startBridge(port = WS_PORT) {
// Run directly if invoked as main module
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
startUiBridge()
startBridge()
startUiBridge().then(() => startBridge())
}

View File

@@ -461,6 +461,61 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
.collect()
}
/// Resolve a single conflict file by choosing "ours" or "theirs" strategy,
/// then stage the result.
pub fn git_resolve_conflict(vault_path: &str, file: &str, strategy: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let checkout_flag = match strategy {
"ours" => "--ours",
"theirs" => "--theirs",
_ => {
return Err(format!(
"Invalid strategy '{}': must be 'ours' or 'theirs'",
strategy
))
}
};
run_git(vault, &["checkout", checkout_flag, "--", file])?;
run_git(vault, &["add", "--", file])?;
Ok(())
}
/// Commit after all merge conflicts have been resolved.
pub fn git_commit_conflict_resolution(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
// Verify no remaining conflicts
let remaining = get_conflict_files(vault_path)?;
if !remaining.is_empty() {
return Err(format!(
"Cannot commit: {} file(s) still have unresolved conflicts",
remaining.len()
));
}
let commit = Command::new("git")
.args(["commit", "-m", "Resolve merge conflicts"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git commit: {}", e))?;
if !commit.status.success() {
let stderr = String::from_utf8_lossy(&commit.stderr);
let stdout = String::from_utf8_lossy(&commit.stdout);
let detail = if stderr.trim().is_empty() {
stdout
} else {
stderr
};
return Err(format!("git commit failed: {}", detail.trim()));
}
Ok(String::from_utf8_lossy(&commit.stdout).to_string())
}
/// Push to remote.
pub fn git_push(vault_path: &str) -> Result<String, String> {
let vault = Path::new(vault_path);
@@ -1217,6 +1272,113 @@ mod tests {
);
}
/// Set up a pair of clones that have a merge conflict on the same file.
/// Returns (bare, clone_a, clone_b) where clone_b has an unresolved conflict.
fn setup_conflict_pair() -> (TempDir, TempDir, TempDir) {
let (bare_dir, clone_a_dir, clone_b_dir) = setup_remote_pair();
let vp_a = clone_a_dir.path().to_str().unwrap();
let vp_b = clone_b_dir.path().to_str().unwrap();
// A creates the file and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Original\n").unwrap();
git_commit(vp_a, "create conflict.md").unwrap();
git_push(vp_a).unwrap();
// B pulls to get the file
git_pull(vp_b).unwrap();
// A modifies and pushes
fs::write(clone_a_dir.path().join("conflict.md"), "# Version A\n").unwrap();
git_commit(vp_a, "A's change").unwrap();
git_push(vp_a).unwrap();
// B modifies the same file locally and commits
fs::write(clone_b_dir.path().join("conflict.md"), "# Version B\n").unwrap();
git_commit(vp_b, "B's change").unwrap();
// B pulls — this causes a merge conflict
let result = git_pull(vp_b).unwrap();
assert_eq!(result.status, "conflict");
(bare_dir, clone_a_dir, clone_b_dir)
}
#[test]
fn test_resolve_conflict_ours() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let conflicts = get_conflict_files(vp_b).unwrap();
assert!(conflicts.contains(&"conflict.md".to_string()));
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version B\n");
}
#[test]
fn test_resolve_conflict_theirs() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
git_resolve_conflict(vp_b, "conflict.md", "theirs").unwrap();
let remaining = get_conflict_files(vp_b).unwrap();
assert!(remaining.is_empty());
let content = fs::read_to_string(clone_b.path().join("conflict.md")).unwrap();
assert_eq!(content, "# Version A\n");
}
#[test]
fn test_resolve_conflict_invalid_strategy() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
let result = git_resolve_conflict(vp_b, "conflict.md", "invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid strategy"));
}
#[test]
fn test_commit_conflict_resolution() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
// Resolve all conflicts first
git_resolve_conflict(vp_b, "conflict.md", "ours").unwrap();
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_ok());
// Verify the merge commit exists
let log = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(clone_b.path())
.output()
.unwrap();
let log_str = String::from_utf8_lossy(&log.stdout);
assert!(log_str.contains("Resolve merge conflicts"));
}
#[test]
fn test_commit_conflict_resolution_fails_with_unresolved() {
let (_bare, _clone_a, clone_b) = setup_conflict_pair();
let vp_b = clone_b.path().to_str().unwrap();
// Don't resolve — try to commit directly
let result = git_commit_conflict_resolution(vp_b);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("still have unresolved conflicts"));
}
#[test]
fn test_parse_github_repo_path_non_github() {
assert_eq!(

486
src-tauri/src/indexing.rs Normal file
View File

@@ -0,0 +1,486 @@
use serde::Serialize;
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
static QMD_PATH_CACHE: Mutex<Option<String>> = Mutex::new(None);
/// Locate the qmd binary, checking known locations and PATH.
/// Caches the result for subsequent calls.
pub fn find_qmd_binary() -> Option<String> {
if let Ok(guard) = QMD_PATH_CACHE.lock() {
if let Some(ref cached) = *guard {
return Some(cached.clone());
}
}
let result = find_qmd_binary_uncached();
if let Some(ref path) = result {
if let Ok(mut guard) = QMD_PATH_CACHE.lock() {
*guard = Some(path.clone());
}
}
result
}
fn find_qmd_binary_uncached() -> Option<String> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()),
Some("/usr/local/bin/qmd".to_string()),
Some("/opt/homebrew/bin/qmd".to_string()),
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(candidate);
}
}
// Fallback: try PATH
Command::new("which")
.arg("qmd")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
/// Clear the cached qmd path (e.g. after auto-install).
pub fn clear_qmd_cache() {
if let Ok(mut guard) = QMD_PATH_CACHE.lock() {
*guard = None;
}
}
#[derive(Debug, Serialize, Clone)]
pub struct IndexStatus {
pub available: bool,
pub qmd_installed: bool,
pub collection_exists: bool,
pub indexed_count: usize,
pub embedded_count: usize,
pub pending_embed: usize,
}
/// Check whether the vault has a qmd index and its status.
pub fn check_index_status(vault_path: &str) -> IndexStatus {
let qmd_bin = match find_qmd_binary() {
Some(b) => b,
None => {
return IndexStatus {
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
}
}
};
let vault_name = vault_dir_name(vault_path);
let output = Command::new(&qmd_bin).args(["status"]).output();
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
parse_status_for_vault(&stdout, &vault_name)
}
_ => IndexStatus {
available: false,
qmd_installed: true,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
},
}
}
fn vault_dir_name(vault_path: &str) -> String {
Path::new(vault_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("laputa")
.to_lowercase()
}
fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus {
let mut collection_exists = false;
let mut indexed_count = 0;
let mut embedded_count = 0;
let mut pending_embed = 0;
// Look for collection section matching vault name
let mut in_vault_section = false;
for line in status_output.lines() {
let trimmed = line.trim();
// Collection headers look like: " laputa (qmd://laputa/)"
if trimmed.contains(&format!("qmd://{vault_name}/")) {
collection_exists = true;
in_vault_section = true;
continue;
}
// New collection section starts
if trimmed.contains("qmd://") && !trimmed.contains(vault_name) {
in_vault_section = false;
continue;
}
if in_vault_section {
if let Some(count_str) = extract_count_from_line(trimmed, "Files:") {
indexed_count = count_str;
}
}
}
// Global counts from the Documents section
for line in status_output.lines() {
let trimmed = line.trim();
if trimmed.starts_with("Total:") {
if let Some(n) = extract_first_number(trimmed) {
if embedded_count == 0 && indexed_count == 0 {
indexed_count = n;
}
}
} else if trimmed.starts_with("Vectors:") {
if let Some(n) = extract_first_number(trimmed) {
embedded_count = n;
}
} else if trimmed.starts_with("Pending:") {
if let Some(n) = extract_first_number(trimmed) {
pending_embed = n;
}
}
}
IndexStatus {
available: true,
qmd_installed: true,
collection_exists,
indexed_count,
embedded_count,
pending_embed,
}
}
fn extract_count_from_line(line: &str, prefix: &str) -> Option<usize> {
if !line.starts_with(prefix) {
return None;
}
extract_first_number(line)
}
fn extract_first_number(s: &str) -> Option<usize> {
s.split_whitespace()
.find_map(|word| word.parse::<usize>().ok())
}
/// Ensure a qmd collection exists for this vault. Creates one if missing.
pub fn ensure_collection(vault_path: &str) -> Result<(), String> {
let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?;
let vault_name = vault_dir_name(vault_path);
// Check if collection already exists
let output = Command::new(&qmd_bin)
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains(&format!("qmd://{vault_name}/")) {
return Ok(());
}
}
// Create collection
Command::new(&qmd_bin)
.args([
"collection",
"add",
vault_path,
"--name",
&vault_name,
"--mask",
"**/*.md",
])
.output()
.map_err(|e| format!("Failed to create collection: {e}"))?;
Ok(())
}
#[derive(Debug, Serialize, Clone)]
pub struct IndexingProgress {
pub phase: String,
pub current: usize,
pub total: usize,
pub done: bool,
pub error: Option<String>,
}
/// Run full indexing: update + embed. Returns progress updates via callback.
pub fn run_full_index<F>(vault_path: &str, on_progress: F) -> Result<(), String>
where
F: Fn(IndexingProgress),
{
let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?;
ensure_collection(vault_path)?;
// Phase 1: update (scan files)
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: 0,
total: 0,
done: false,
error: None,
});
let update_output = Command::new(&qmd_bin)
.args(["update"])
.output()
.map_err(|e| format!("qmd update failed: {e}"))?;
if !update_output.status.success() {
let stderr = String::from_utf8_lossy(&update_output.stderr);
let err = format!("qmd update failed: {stderr}");
on_progress(IndexingProgress {
phase: "error".to_string(),
current: 0,
total: 0,
done: true,
error: Some(err.clone()),
});
return Err(err);
}
// Parse update output for counts
let update_stdout = String::from_utf8_lossy(&update_output.stdout);
let total = parse_indexed_count(&update_stdout);
on_progress(IndexingProgress {
phase: "scanning".to_string(),
current: total,
total,
done: false,
error: None,
});
// Phase 2: embed (generate vectors)
on_progress(IndexingProgress {
phase: "embedding".to_string(),
current: 0,
total,
done: false,
error: None,
});
let embed_output = Command::new(&qmd_bin)
.args(["embed"])
.output()
.map_err(|e| format!("qmd embed failed: {e}"))?;
if !embed_output.status.success() {
let stderr = String::from_utf8_lossy(&embed_output.stderr);
// Embedding failure is non-fatal — keyword search still works
log::warn!("qmd embed failed (keyword search still works): {stderr}");
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
total,
done: true,
error: Some("Embedding failed — keyword search only".to_string()),
});
return Ok(());
}
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
total,
done: true,
error: None,
});
Ok(())
}
fn parse_indexed_count(update_output: &str) -> usize {
// qmd update output typically contains lines like "Indexed 9078 files"
for line in update_output.lines() {
if let Some(n) = extract_first_number(line) {
return n;
}
}
0
}
/// Run incremental update for a single file change.
pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
let qmd_bin = find_qmd_binary().ok_or("qmd not installed")?;
// Verify collection exists
let vault_name = vault_dir_name(vault_path);
let list_output = Command::new(&qmd_bin)
.args(["collection", "list"])
.output()
.map_err(|e| format!("Failed to list collections: {e}"))?;
if list_output.status.success() {
let stdout = String::from_utf8_lossy(&list_output.stdout);
if !stdout.contains(&format!("qmd://{vault_name}/")) {
// Collection doesn't exist yet — skip incremental, full index needed
return Ok(());
}
}
let output = Command::new(&qmd_bin)
.args(["update"])
.output()
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd update failed: {stderr}"));
}
Ok(())
}
/// Attempt to auto-install qmd via bun. Returns Ok if successful.
pub fn auto_install_qmd() -> Result<String, String> {
// Find bun
let bun = find_bun().ok_or("bun not installed — cannot auto-install qmd")?;
let output = Command::new(&bun)
.args(["install", "-g", "qmd"])
.output()
.map_err(|e| format!("Failed to install qmd: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("qmd installation failed: {stderr}"));
}
// Clear cache so find_qmd_binary() re-discovers
clear_qmd_cache();
match find_qmd_binary() {
Some(path) => Ok(path),
None => Err("qmd installed but binary not found".to_string()),
}
}
fn find_bun() -> Option<String> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/bun").to_string_lossy().to_string()),
Some("/opt/homebrew/bin/bun".to_string()),
Some("/usr/local/bin/bun".to_string()),
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(candidate);
}
}
Command::new("which")
.arg("bun")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vault_dir_name_extracts_last_segment() {
assert_eq!(vault_dir_name("/Users/luca/Laputa"), "laputa");
assert_eq!(vault_dir_name("/home/user/MyVault"), "myvault");
}
#[test]
fn vault_dir_name_fallback() {
assert_eq!(vault_dir_name(""), "laputa");
}
#[test]
fn extract_first_number_works() {
assert_eq!(
extract_first_number("Total: 9078 files indexed"),
Some(9078)
);
assert_eq!(extract_first_number("Vectors: 14676 embedded"), Some(14676));
assert_eq!(extract_first_number("no numbers here"), None);
}
#[test]
fn parse_status_finds_collection() {
let status = r#"
QMD Status
Index: /Users/luca/.cache/qmd/index.sqlite
Size: 100.9 MB
Documents
Total: 9115 files indexed
Vectors: 14676 embedded
Pending: 26 need embedding
Collections
laputa (qmd://laputa/)
Pattern: **/*.md
Files: 9078 (updated 20d ago)
"#;
let result = parse_status_for_vault(status, "laputa");
assert!(result.collection_exists);
assert_eq!(result.indexed_count, 9078);
assert_eq!(result.embedded_count, 14676);
assert_eq!(result.pending_embed, 26);
}
#[test]
fn parse_status_missing_collection() {
let status = r#"
QMD Status
Documents
Total: 100 files indexed
Vectors: 50 embedded
Pending: 0
Collections
other (qmd://other/)
Files: 100
"#;
let result = parse_status_for_vault(status, "laputa");
assert!(!result.collection_exists);
}
#[test]
fn extract_count_from_line_works() {
assert_eq!(
extract_count_from_line("Files: 9078 (updated 20d ago)", "Files:"),
Some(9078)
);
assert_eq!(extract_count_from_line("Pattern: **/*.md", "Files:"), None);
}
#[test]
fn parse_indexed_count_from_output() {
assert_eq!(parse_indexed_count("Indexed 342 files in 1.2s"), 342);
assert_eq!(parse_indexed_count("No output"), 0);
}
}

View File

@@ -3,6 +3,7 @@ pub mod claude_cli;
pub mod frontmatter;
pub mod git;
pub mod github;
pub mod indexing;
pub mod mcp;
pub mod menu;
pub mod search;
@@ -21,6 +22,7 @@ use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeS
use frontmatter::FrontmatterValue;
use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile};
use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use indexing::{IndexStatus, IndexingProgress};
use search::SearchResponse;
use settings::Settings;
use theme::{ThemeFile, VaultSettings};
@@ -131,6 +133,18 @@ fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
git::git_pull(&vault_path)
}
#[tauri::command]
fn git_resolve_conflict(vault_path: String, file: String, strategy: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
git::git_resolve_conflict(&vault_path, &file, &strategy)
}
#[tauri::command]
fn git_commit_conflict_resolution(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
git::git_commit_conflict_resolution(&vault_path)
}
#[tauri::command]
fn git_push(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
@@ -341,6 +355,64 @@ async fn search_vault(
.map_err(|e| format!("Search task failed: {}", e))?
}
#[tauri::command]
fn get_index_status(vault_path: String) -> IndexStatus {
let vault_path = expand_tilde(&vault_path);
indexing::check_index_status(&vault_path)
}
#[tauri::command]
async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Result<(), String> {
use tauri::Emitter;
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || {
// Auto-install qmd if not available
if indexing::find_qmd_binary().is_none() {
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "installing".to_string(),
current: 0,
total: 0,
done: false,
error: None,
},
);
match indexing::auto_install_qmd() {
Ok(_) => log::info!("qmd auto-installed successfully"),
Err(e) => {
log::warn!("qmd auto-install failed: {e}");
let _ = app_handle.emit(
"indexing-progress",
IndexingProgress {
phase: "error".to_string(),
current: 0,
total: 0,
done: true,
error: Some(format!("qmd not available: {e}")),
},
);
return Err(e);
}
}
}
indexing::run_full_index(&vault_path, |progress| {
let _ = app_handle.emit("indexing-progress", &progress);
})
})
.await
.map_err(|e| format!("Indexing task failed: {e}"))?
}
#[tauri::command]
async fn trigger_incremental_index(vault_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || indexing::run_incremental_update(&vault_path))
.await
.map_err(|e| format!("Incremental index failed: {e}"))?
}
struct WsBridgeChild(Mutex<Option<Child>>);
#[tauri::command]
@@ -376,9 +448,9 @@ fn save_vault_settings(vault_path: String, settings: VaultSettings) -> Result<()
}
#[tauri::command]
fn set_active_theme(vault_path: String, theme_id: String) -> Result<(), String> {
fn set_active_theme(vault_path: String, theme_id: Option<String>) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
theme::set_active_theme(&vault_path, &theme_id)
theme::set_active_theme(&vault_path, theme_id.as_deref())
}
#[tauri::command]
@@ -543,6 +615,8 @@ pub fn run() {
get_last_commit_info,
git_pull,
git_push,
git_resolve_conflict,
git_commit_conflict_resolution,
ai_chat,
check_claude_cli,
stream_claude_chat,
@@ -566,6 +640,9 @@ pub fn run() {
github_device_flow_poll,
github_get_user,
search_vault,
get_index_status,
start_indexing,
trigger_incremental_index,
create_getting_started_vault,
check_vault_exists,
get_default_vault_path,

View File

@@ -23,6 +23,7 @@ const NOTE_TRASH: &str = "note-trash";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
const VIEW_GO_BACK: &str = "view-go-back";
const VIEW_GO_FORWARD: &str = "view-go-forward";
const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
@@ -44,6 +45,7 @@ const CUSTOM_IDS: &[&str] = &[
VIEW_ZOOM_RESET,
VIEW_GO_BACK,
VIEW_GO_FORWARD,
APP_CHECK_FOR_UPDATES,
];
/// IDs of menu items that should be disabled when no note tab is active.
@@ -56,10 +58,15 @@ fn build_app_menu(app: &App) -> MenuResult {
.id(APP_SETTINGS)
.accelerator("CmdOrCtrl+,")
.build(app)?;
let check_updates_item = MenuItemBuilder::new("Check for Updates...")
.id(APP_CHECK_FOR_UPDATES)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Laputa")
.about(None)
.separator()
.item(&check_updates_item)
.separator()
.item(&settings_item)
.separator()
.services()
@@ -269,6 +276,7 @@ mod tests {
"view-zoom-reset",
"view-go-back",
"view-go-forward",
"app-check-for-updates",
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");

View File

@@ -1,3 +1,4 @@
use crate::indexing;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
@@ -30,31 +31,6 @@ struct QmdResult {
pub score: f64,
}
fn find_qmd_binary() -> Option<String> {
let candidates = [
dirs::home_dir().map(|h| h.join(".bun/bin/qmd").to_string_lossy().to_string()),
Some("/usr/local/bin/qmd".to_string()),
Some("/opt/homebrew/bin/qmd".to_string()),
];
for candidate in candidates.into_iter().flatten() {
if Path::new(&candidate).exists() {
return Some(candidate);
}
}
// Fallback: try PATH
Command::new("which")
.arg("qmd")
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
}
fn qmd_uri_to_vault_path(uri: &str, vault_path: &str) -> String {
// qmd://laputa/essay/foo.md → essay/foo.md
let relative = uri
@@ -108,7 +84,7 @@ fn detect_collection_name(vault_path: &str) -> String {
}
fn detect_collection_name_uncached(vault_path: &str) -> String {
let qmd_bin = match find_qmd_binary() {
let qmd_bin = match indexing::find_qmd_binary() {
Some(b) => b,
None => return "laputa".to_string(),
};
@@ -149,8 +125,8 @@ pub fn search_vault(
) -> Result<SearchResponse, String> {
let start = Instant::now();
let qmd_bin =
find_qmd_binary().ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?;
let qmd_bin = indexing::find_qmd_binary()
.ok_or_else(|| "qmd binary not found. Install qmd first.".to_string())?;
let collection = detect_collection_name(vault_path);

View File

@@ -98,10 +98,10 @@ pub fn save_vault_settings(vault_path: &str, settings: VaultSettings) -> Result<
.map_err(|e| format!("Failed to write vault settings: {e}"))
}
/// Set the active theme in vault settings.
pub fn set_active_theme(vault_path: &str, theme_id: &str) -> Result<(), String> {
/// Set the active theme in vault settings. Pass `None` to clear.
pub fn set_active_theme(vault_path: &str, theme_id: Option<&str>) -> Result<(), String> {
let mut settings = get_vault_settings(vault_path)?;
settings.theme = Some(theme_id.to_string());
settings.theme = theme_id.map(|s| s.to_string());
save_vault_settings(vault_path, settings)
}
@@ -684,9 +684,14 @@ mod tests {
assert!(settings.theme.is_none());
// Set and read back
set_active_theme(vp, "dark").unwrap();
set_active_theme(vp, Some("dark")).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme.as_deref(), Some("dark"));
// Clear theme
set_active_theme(vp, None).unwrap();
let settings = get_vault_settings(vp).unwrap();
assert_eq!(settings.theme, None);
}
#[test]

View File

@@ -68,6 +68,9 @@ pub struct VaultEntry {
/// 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>,
/// Default sort preference for the note list when viewing instances of this Type.
/// Stored as "option:direction" (e.g. "modified:desc", "title:asc", "property:Priority:asc").
pub sort: Option<String>,
/// Word count of the note body (excludes frontmatter and H1 title).
#[serde(rename = "wordCount")]
pub word_count: u32,
@@ -118,6 +121,8 @@ struct Frontmatter {
sidebar_label: Option<String>,
#[serde(default)]
template: Option<String>,
#[serde(default)]
sort: Option<String>,
}
/// Handles YAML fields that can be either a single string or a list of strings.
@@ -163,6 +168,7 @@ const SKIP_KEYS: &[&str] = &[
"order",
"sidebar label",
"template",
"sort",
];
/// Extract all wikilink-containing fields from raw YAML frontmatter.
@@ -387,6 +393,7 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
order: frontmatter.order,
sidebar_label: frontmatter.sidebar_label,
template: frontmatter.template,
sort: frontmatter.sort,
word_count,
outgoing_links,
properties,
@@ -1183,6 +1190,40 @@ References:
assert!(entry.relationships.get("template").is_none());
}
// --- sort field tests ---
#[test]
fn test_parse_sort_from_type_entry() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsort: \"modified:desc\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.sort, Some("modified:desc".to_string()));
}
#[test]
fn test_parse_sort_missing_defaults_to_none() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert_eq!(entry.sort, None);
}
#[test]
fn test_sort_not_in_relationships() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.relationships.get("sort").is_none());
}
#[test]
fn test_sort_not_in_properties() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Type\nsort: \"title:asc\"\n---\n# Project\n";
let entry = parse_test_entry(&dir, "type/project.md", content);
assert!(entry.properties.get("sort").is_none());
}
// --- custom properties tests ---
#[test]

View File

@@ -34,7 +34,7 @@ const mockEntries = [
modifiedAt: 1700000000,
createdAt: null,
fileSize: 1024,
template: null,
template: null, sort: null,
outgoingLinks: [],
},
{
@@ -51,7 +51,7 @@ const mockEntries = [
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
template: null,
template: null, sort: null,
outgoingLinks: [],
},
]

View File

@@ -17,7 +17,6 @@ import { useMcpRegistration } from './hooks/useMcpRegistration'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
import { useEditorSave } from './hooks/useEditorSave'
import { useCommitFlow } from './hooks/useCommitFlow'
import { useViewMode } from './hooks/useViewMode'
import { useEntryActions } from './hooks/useEntryActions'
@@ -25,17 +24,21 @@ import { useAppCommands } from './hooks/useAppCommands'
import { useDialogs } from './hooks/useDialogs'
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater } from './hooks/useUpdater'
import { useUpdater, restartApp } from './hooks/useUpdater'
import { useNavigationHistory } from './hooks/useNavigationHistory'
import { useAutoSync } from './hooks/useAutoSync'
import { useConflictResolver } from './hooks/useConflictResolver'
import { useIndexing } from './hooks/useIndexing'
import { useZoom } from './hooks/useZoom'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
import { useNavigationGestures } from './hooks/useNavigationGestures'
import { ConflictResolverModal } from './components/ConflictResolverModal'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import { extractOutgoingLinks } from './utils/wikilinks'
import type { SidebarSelection } from './types'
import './App.css'
@@ -77,34 +80,6 @@ function useLayoutPanels() {
}
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
function useEditorSaveWithLinks(config: {
updateContent: (path: string, content: string) => void
updateEntry: (path: string, patch: Partial<import('./types').VaultEntry>) => void
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
setToastMessage: (msg: string | null) => void
onAfterSave: () => void
onNotePersisted?: (path: string) => void
}) {
const { updateContent, updateEntry } = config
const saveContent = useCallback((path: string, content: string) => {
updateContent(path, content)
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
}, [updateContent, updateEntry])
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
const links = extractOutgoingLinks(content)
const key = links.join('\0')
if (key !== prevLinksKeyRef.current) {
prevLinksKeyRef.current = key
updateEntry(path, { outgoingLinks: links })
}
}, [rawOnChange, updateEntry])
return { ...editor, handleContentChange }
}
function App() {
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
const layout = useLayoutPanels()
@@ -135,11 +110,40 @@ function App() {
onVaultUpdated: vault.reloadVault,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names}review needed`)
setToastMessage(`Conflict in ${names}click to resolve`)
},
onToast: (msg) => setToastMessage(msg),
})
// Ref bridges for conflict resolution callbacks (notes declared below)
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
const indexing = useIndexing(resolvedPath)
const conflictResolver = useConflictResolver({
vaultPath: resolvedPath,
onResolved: () => {
dialogs.closeConflictResolver()
autoSync.resumePull()
vault.reloadVault()
autoSync.triggerSync()
},
onToast: (msg) => setToastMessage(msg),
onOpenFile: (relativePath) => openConflictFileRef.current(relativePath),
})
const handleOpenConflictResolver = useCallback(() => {
if (autoSync.conflictFiles.length === 0) return
autoSync.pausePull()
conflictResolver.initFiles(autoSync.conflictFiles)
dialogs.openConflictResolver()
}, [autoSync, conflictResolver, dialogs])
const handleCloseConflictResolver = useCallback(() => {
autoSync.resumePull()
dialogs.closeConflictResolver()
}, [autoSync, dialogs])
// Ref bridges handleContentChange (created after notes) into useNoteActions.
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
@@ -185,53 +189,33 @@ function App() {
}
}, [navHistory, isEntryExists, vault.entries, notes])
// Mouse button 3/4 (back/forward) and macOS trackpad two-finger swipe
useEffect(() => {
const handleMouseBack = (e: MouseEvent) => {
if (e.button === 3) { e.preventDefault(); handleGoBack() }
if (e.button === 4) { e.preventDefault(); handleGoForward() }
}
window.addEventListener('mouseup', handleMouseBack)
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
// Trackpad swipe: accumulate horizontal wheel delta and trigger on threshold
let accumulatedDeltaX = 0
let resetTimer: ReturnType<typeof setTimeout> | null = null
const SWIPE_THRESHOLD = 120
const handleWheel = (e: WheelEvent) => {
// Only handle horizontal-dominant gestures (trackpad swipe)
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
if (e.ctrlKey || e.metaKey) return // ignore pinch-zoom
accumulatedDeltaX += e.deltaX
if (resetTimer) clearTimeout(resetTimer)
resetTimer = setTimeout(() => { accumulatedDeltaX = 0 }, 300)
if (accumulatedDeltaX > SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
handleGoForward()
} else if (accumulatedDeltaX < -SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
handleGoBack()
}
}
window.addEventListener('wheel', handleWheel, { passive: true })
return () => {
window.removeEventListener('mouseup', handleMouseBack)
window.removeEventListener('wheel', handleWheel)
if (resetTimer) clearTimeout(resetTimer)
}
}, [handleGoBack, handleGoForward])
const { triggerIncrementalIndex } = indexing
const onAfterSave = useCallback(() => {
vault.loadModifiedFiles()
triggerIncrementalIndex()
}, [vault, triggerIncrementalIndex])
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
setTabs: notes.setTabs, setToastMessage, onAfterSave: vault.loadModifiedFiles,
setTabs: notes.setTabs, setToastMessage, onAfterSave,
onNotePersisted: vault.clearUnsaved,
})
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
// Wire conflict file opener now that notes is available
useEffect(() => {
openConflictFileRef.current = (relativePath: string) => {
const fullPath = `${resolvedPath}/${relativePath}`
const entry = vault.entries.find(e => e.path === fullPath)
if (entry) {
notes.handleSelectNote(entry)
dialogs.closeConflictResolver()
}
}
}, [resolvedPath, vault.entries, notes, dialogs])
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
const handleSave = useCallback(async () => {
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
@@ -296,6 +280,14 @@ function App() {
const { status: updateStatus, actions: updateActions } = useUpdater()
const handleCheckForUpdates = useCallback(async () => {
if (updateStatus.state === 'downloading') {
setToastMessage('Update is downloading…')
return
}
if (updateStatus.state === 'ready') {
await restartApp()
return
}
const result = await updateActions.checkForUpdates()
if (result === 'up-to-date') {
setToastMessage("You're on the latest version")
@@ -303,7 +295,7 @@ function App() {
setToastMessage('Could not check for updates')
}
// 'available' → UpdateBanner handles it automatically
}, [updateActions, setToastMessage])
}, [updateActions, updateStatus.state, setToastMessage])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
@@ -321,7 +313,10 @@ function App() {
onOpenSettings: dialogs.openSettings,
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
onCommitPush: commitFlow.openCommitDialog,
onResolveConflicts: handleOpenConflictResolver,
conflictCount: autoSync.conflictFiles.length,
onSetViewMode: setViewMode,
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
onToggleDiff: () => diffToggleRef.current(),
onToggleRawEditor: () => rawToggleRef.current(),
@@ -335,9 +330,13 @@ function App() {
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
onSwitchTheme: themeManager.switchTheme,
onCreateTheme: async () => {
await themeManager.createTheme()
await vault.reloadVault()
const path = await themeManager.createTheme()
const freshEntries = await vault.reloadVault()
setSelection({ kind: 'sectionGroup', type: 'Theme' })
if (path) {
const entry = freshEntries.find(e => e.path === path)
if (entry) notes.handleSelectNote(entry)
}
},
onOpenTheme: (themeId: string) => {
const entry = vault.entries.find(e => e.path === themeId)
@@ -347,7 +346,6 @@ function App() {
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
isUpdating: updateStatus.state === 'downloading' || updateStatus.state === 'ready',
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
@@ -399,7 +397,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} modifiedFilesError={vault.modifiedFilesError} 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} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
</>
@@ -452,13 +450,24 @@ function App() {
</div>
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onRemoveVault={vaultSwitcher.removeVault} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} indexingProgress={indexing.progress} onRemoveVault={vaultSwitcher.removeVault} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<ConflictResolverModal
open={dialogs.showConflictResolver}
fileStates={conflictResolver.fileStates}
allResolved={conflictResolver.allResolved}
committing={conflictResolver.committing}
error={conflictResolver.error}
onResolveFile={conflictResolver.resolveFile}
onOpenInEditor={conflictResolver.openInEditor}
onCommit={conflictResolver.commitResolution}
onClose={handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} themeManager={themeManager} />
<GitHubVaultModal
open={dialogs.showGitHubVault}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { AiPanel } from './AiPanel'
import type { VaultEntry } from '../types'
@@ -133,4 +133,33 @@ describe('AiPanel', () => {
const input = screen.getByTestId('agent-input') as HTMLInputElement
expect(input.placeholder).toBe('Ask the AI agent...')
})
it('auto-focuses input on mount', async () => {
vi.useFakeTimers()
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
await act(() => { vi.advanceTimersByTime(1) })
const input = screen.getByTestId('agent-input')
expect(document.activeElement).toBe(input)
vi.useRealTimers()
})
it('calls onClose when Escape is pressed while panel has focus', async () => {
vi.useFakeTimers()
const onClose = vi.fn()
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
await act(() => { vi.advanceTimersByTime(1) })
// Input is focused inside the panel, so Escape should trigger onClose
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
vi.useRealTimers()
})
it('calls onClose when Escape is pressed on panel element', () => {
const onClose = vi.fn()
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
const panel = screen.getByTestId('ai-panel')
panel.focus()
fireEvent.keyDown(panel, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
import { AiMessage } from './AiMessage'
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
@@ -103,10 +103,10 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
)
}
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext }: {
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext, inputRef }: {
input: string; onInputChange: (v: string) => void
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
isActive: boolean; hasContext: boolean
isActive: boolean; hasContext: boolean; inputRef: React.RefObject<HTMLInputElement | null>
}) {
const sendDisabled = isActive || !input.trim()
return (
@@ -116,6 +116,7 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContex
>
<div className="flex items-end gap-2">
<input
ref={inputRef}
value={input}
onChange={e => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
@@ -150,6 +151,8 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContex
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
const [input, setInput] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const panelRef = useRef<HTMLElement>(null)
const linkedEntries = useMemo(() => {
if (!activeEntry || !entries) return []
@@ -163,9 +166,33 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
const agent = useAiAgent(vaultPath, contextPrompt)
const hasContext = !!activeEntry
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(timer)
}, [])
useEffect(() => {
if (isActive) {
panelRef.current?.focus()
} else {
inputRef.current?.focus()
}
}, [isActive])
const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
e.preventDefault()
onClose()
}
}, [onClose])
useEffect(() => {
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [handleEscape])
const handleSend = () => {
if (!input.trim() || isActive) return
agent.sendMessage(input)
@@ -181,7 +208,10 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
return (
<aside
ref={panelRef}
tabIndex={-1}
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
style={{ outline: 'none' }}
data-testid="ai-panel"
>
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
@@ -201,6 +231,7 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
onKeyDown={handleKeyDown}
isActive={isActive}
hasContext={hasContext}
inputRef={inputRef}
/>
</aside>
)

View File

@@ -0,0 +1,246 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Check, Loader2 } from 'lucide-react'
import type { ConflictFileState } from '../hooks/useConflictResolver'
interface ConflictResolverModalProps {
open: boolean
fileStates: ConflictFileState[]
allResolved: boolean
committing: boolean
error: string | null
onResolveFile: (file: string, strategy: 'ours' | 'theirs') => void
onOpenInEditor: (file: string) => void
onCommit: () => void
onClose: () => void
}
function isBinaryFile(file: string): boolean {
const binaryExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.tar', '.gz', '.mp3', '.mp4', '.wav', '.ogg', '.woff', '.woff2', '.ttf', '.otf', '.eot']
return binaryExts.some(ext => file.toLowerCase().endsWith(ext))
}
function fileName(path: string): string {
return path.split('/').pop() ?? path
}
function ResolutionLabel({ resolution }: { resolution: ConflictFileState['resolution'] }) {
if (!resolution) return null
const labels = { ours: 'Keeping mine', theirs: 'Keeping theirs', manual: 'Edited manually' }
return (
<span className="flex items-center gap-1 text-xs text-green-600">
<Check size={12} />{labels[resolution]}
</span>
)
}
function ConflictFileRow({
state,
focused,
onResolve,
onOpenInEditor,
onFocus,
}: {
state: ConflictFileState
focused: boolean
onResolve: (strategy: 'ours' | 'theirs') => void
onOpenInEditor: () => void
onFocus: () => void
}) {
const rowRef = useRef<HTMLDivElement>(null)
const binary = isBinaryFile(state.file)
const resolved = state.resolution !== null
useEffect(() => {
if (focused) rowRef.current?.scrollIntoView({ block: 'nearest' })
}, [focused])
return (
<div
ref={rowRef}
role="row"
tabIndex={0}
onFocus={onFocus}
className={`flex items-center justify-between gap-2 rounded-md border px-3 py-2 transition-colors ${
focused ? 'border-ring bg-accent/50' : 'border-border bg-background'
} ${resolved ? 'opacity-70' : ''}`}
data-testid={`conflict-file-${state.file}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText size={14} className="shrink-0 text-muted-foreground" />
<span className="text-sm truncate" title={state.file}>{fileName(state.file)}</span>
<ResolutionLabel resolution={state.resolution} />
</div>
<div className="flex items-center gap-1 shrink-0">
{state.resolving ? (
<Loader2 size={14} className="animate-spin text-muted-foreground" />
) : (
<>
<Button
variant="outline"
size="sm"
className="text-xs h-7 px-2"
onClick={() => onResolve('ours')}
disabled={state.resolving}
title="Keep my local version (K)"
data-testid={`resolve-ours-${state.file}`}
>
Keep mine
</Button>
<Button
variant="outline"
size="sm"
className="text-xs h-7 px-2"
onClick={() => onResolve('theirs')}
disabled={state.resolving}
title="Keep remote version (T)"
data-testid={`resolve-theirs-${state.file}`}
>
Keep theirs
</Button>
{!binary && (
<Button
variant="ghost"
size="sm"
className="text-xs h-7 px-2"
onClick={onOpenInEditor}
title="Open file in editor (O)"
data-testid={`resolve-open-${state.file}`}
>
Open in editor
</Button>
)}
</>
)}
</div>
</div>
)
}
export function ConflictResolverModal({
open,
fileStates,
allResolved,
committing,
error,
onResolveFile,
onOpenInEditor,
onCommit,
onClose,
}: ConflictResolverModalProps) {
const [focusIdx, setFocusIdx] = useState(0)
const focusIdxRef = useRef(0)
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open) {
setFocusIdx(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
focusIdxRef.current = 0
}
}, [open])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
return
}
const idx = focusIdxRef.current
const file = fileStates[idx]
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault()
const next = Math.min(idx + 1, fileStates.length - 1)
setFocusIdx(next)
focusIdxRef.current = next
return
}
if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault()
const prev = Math.max(idx - 1, 0)
setFocusIdx(prev)
focusIdxRef.current = prev
return
}
if ((e.key === 'k' || e.key === 'K') && file && !file.resolving && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onResolveFile(file.file, 'ours')
} else if ((e.key === 't' || e.key === 'T') && file && !file.resolving && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onResolveFile(file.file, 'theirs')
} else if ((e.key === 'o' || e.key === 'O') && file && !isBinaryFile(file.file) && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
onOpenInEditor(file.file)
} else if (e.key === 'Enter' && allResolved && !committing) {
e.preventDefault()
onCommit()
}
}, [fileStates, allResolved, committing, onResolveFile, onOpenInEditor, onCommit, onClose])
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent
showCloseButton={false}
className="sm:max-w-[520px]"
onKeyDown={handleKeyDown}
>
<DialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-orange-500" />
<DialogTitle>Resolve Merge Conflicts</DialogTitle>
</div>
<DialogDescription>
{fileStates.length} file{fileStates.length !== 1 ? 's have' : ' has'} merge conflicts. Choose how to resolve each file.
</DialogDescription>
</DialogHeader>
<div
ref={listRef}
className="flex flex-col gap-2 max-h-[300px] overflow-y-auto"
role="grid"
data-testid="conflict-file-list"
>
{fileStates.map((state, i) => (
<ConflictFileRow
key={state.file}
state={state}
focused={i === focusIdx}
onResolve={(strategy) => onResolveFile(state.file, strategy)}
onOpenInEditor={() => onOpenInEditor(state.file)}
onFocus={() => {
setFocusIdx(i)
focusIdxRef.current = i
}}
/>
))}
</div>
{error && (
<p className="text-xs text-destructive" data-testid="conflict-error">{error}</p>
)}
<DialogFooter className="flex-row items-center justify-between sm:justify-between">
<span className="text-[11px] text-muted-foreground">
K = keep mine · T = keep theirs · O = open · Enter = commit
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
onClick={onCommit}
disabled={!allResolved || committing}
data-testid="conflict-commit-btn"
>
{committing ? (
<><Loader2 size={14} className="animate-spin mr-1" />Committing</>
) : (
'Commit & continue'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -51,7 +51,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -75,7 +75,7 @@ const mockEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}

View File

@@ -26,7 +26,7 @@ const mockEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -71,7 +71,7 @@ const referrerEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: ['Test Project'],
}
@@ -376,7 +376,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -403,7 +403,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -430,7 +430,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -457,7 +457,7 @@ This is a test note with some words to count.
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
}
@@ -600,7 +600,7 @@ Status: Active
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
// Body text also links to grow-newsletter
outgoingLinks: ['responsibility/grow-newsletter'],
}

View File

@@ -30,7 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -34,7 +34,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -64,7 +64,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -91,7 +91,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -118,7 +118,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -145,7 +145,7 @@ const mockEntries: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -367,7 +367,7 @@ describe('getSortComparator', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
@@ -481,7 +481,7 @@ describe('NoteList sort controls', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
@@ -739,7 +739,7 @@ const trashedEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}
@@ -767,7 +767,7 @@ const expiredTrashedEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}
@@ -924,7 +924,7 @@ describe('NoteList — virtual list with large datasets', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,
@@ -1254,7 +1254,7 @@ const typeEntry: VaultEntry = {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
import { useDragRegion } from '../hooks/useDragRegion'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../types'
@@ -18,6 +18,7 @@ import {
buildRelationshipGroups, filterEntries,
relativeDate, getDisplayDate,
loadSortPreferences, saveSortPreferences,
parseSortConfig, serializeSortConfig, clearListSortFromLocalStorage,
} from '../utils/noteListHelpers'
interface NoteListProps {
@@ -34,6 +35,8 @@ interface NoteListProps {
onCreateNote: () => void
onBulkArchive?: (paths: string[]) => void
onBulkTrash?: (paths: string[]) => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
@@ -256,11 +259,6 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
const isEntityView = selection.kind === 'entity'
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
const searched = useMemo(() => {
@@ -279,14 +277,26 @@ function useNoteListData({ entries, selection, allContent, query, listSort, list
[isTrashView, searched],
)
return { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount }
return { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount }
}
// --- Pure helpers ---
const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' }
function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record<string, SortConfig>): SortConfig {
if (typeDocument?.sort) {
const parsed = parseSortConfig(typeDocument.sort)
if (parsed) return parsed
}
return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG
}
// --- Main component ---
const defaultGetNoteStatus = (): NoteStatus => 'clean'
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, allContent, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onUpdateTypeSort, updateEntry }: NoteListProps) {
const [search, setSearch] = useState('')
const [searchVisible, setSearchVisible] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
@@ -311,9 +321,43 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
[getNoteStatus, modifiedFiles, modifiedPathSet],
)
// Resolve the type document for sectionGroup selections (needs to be above sort logic)
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
// Resolve list sort config: read from type frontmatter for sectionGroup, else localStorage
const listConfig = resolveListSortConfig(typeDocument, sortPrefs)
// Silent migration: if type has no sort in frontmatter but localStorage has __list__, migrate it
const migrationDoneRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!typeDocument || typeDocument.sort || !onUpdateTypeSort || !updateEntry) return
if (migrationDoneRef.current.has(typeDocument.path)) return
const lsConfig = sortPrefs['__list__']
if (!lsConfig) return
migrationDoneRef.current.add(typeDocument.path)
const serialized = serializeSortConfig(lsConfig)
onUpdateTypeSort(typeDocument.path, 'sort', serialized)
updateEntry(typeDocument.path, { sort: serialized })
clearListSortFromLocalStorage()
}, [typeDocument, sortPrefs, onUpdateTypeSort, updateEntry])
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}, [])
if (groupLabel === '__list__' && typeDocument && onUpdateTypeSort && updateEntry) {
// Persist sort to type file frontmatter
const config: SortConfig = { option, direction }
const serialized = serializeSortConfig(config)
onUpdateTypeSort(typeDocument.path, 'sort', serialized)
updateEntry(typeDocument.path, { sort: serialized })
// Clear old localStorage __list__ entry if present (migration cleanup)
clearListSortFromLocalStorage()
} else {
// Relationship group sorts still use localStorage
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}
}, [typeDocument, onUpdateTypeSort, updateEntry])
const toggleGroup = useCallback((label: string) => {
setCollapsedGroups((prev) => toggleSetMember(prev, label))
@@ -321,7 +365,6 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
const typeEntryMap = useTypeEntryMap(entries)
const query = search.trim().toLowerCase()
const listConfig = sortPrefs['__list__'] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
// Compute custom properties and derive effective sort before sorting entries
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
@@ -332,7 +375,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
return customProperties.includes(opt.slice('property:'.length)) ? opt : 'modified'
}, [listConfig.option, customProperties])
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'
const { isEntityView, isTrashView, typeDocument, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, allContent, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes })
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
const noteListKeyboard = useNoteListKeyboard({

View File

@@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -11,7 +11,7 @@ function entry(title: string, path = `/vault/note/${title}.md`) {
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: [],
sidebarLabel: null, template: null, sort: null, outgoingLinks: [],
properties: {},
}
}

View File

@@ -38,7 +38,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: ['topic/ai', 'topic/api-design', 'person/luca'],
properties: {},
},
@@ -65,7 +65,7 @@ const MOCK_ENTRIES: VaultEntry[] = [
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: ['person/bob'],
properties: {},
},

View File

@@ -349,7 +349,7 @@ function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) {
<button
className="border border-border bg-transparent text-muted-foreground rounded cursor-pointer hover:text-foreground hover:border-foreground"
style={{ fontSize: 12, padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 4, alignSelf: 'flex-start' }}
onClick={() => createTheme(activeThemeId ?? undefined)}
onClick={() => createTheme()}
type="button"
data-testid="create-theme"
>

View File

@@ -40,7 +40,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -68,7 +68,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -96,7 +96,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -124,7 +124,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -152,7 +152,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -180,7 +180,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -208,7 +208,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -236,7 +236,7 @@ const mockEntries: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -457,7 +457,7 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -485,7 +485,7 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -512,7 +512,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -539,7 +539,7 @@ describe('Sidebar', () => {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -627,7 +627,7 @@ describe('Sidebar', () => {
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}

View File

@@ -213,58 +213,89 @@ describe('StatusBar', () => {
expect(screen.getByTitle('View pending changes')).toBeInTheDocument()
})
describe('vault removal', () => {
it('shows remove button for each vault when onRemoveVault is provided and multiple vaults exist', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
expect(screen.getByTestId('vault-menu-remove-Main Vault')).toBeInTheDocument()
expect(screen.getByTestId('vault-menu-remove-Work Vault')).toBeInTheDocument()
})
it('shows indexing badge when indexing is in progress', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'scanning', current: 342, total: 1057, done: false, error: null }}
/>
)
expect(screen.getByTestId('status-indexing')).toBeInTheDocument()
expect(screen.getByText(/Indexing… 342\/1,057/)).toBeInTheDocument()
})
it('does not show remove button when only one vault exists', () => {
const singleVault: VaultOption[] = [{ label: 'Only Vault', path: '/only/vault' }]
render(
<StatusBar noteCount={100} vaultPath="/only/vault" vaults={singleVault} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
expect(screen.queryByTestId('vault-menu-remove-Only Vault')).not.toBeInTheDocument()
})
it('shows embedding phase in indexing badge', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'embedding', current: 50, total: 200, done: false, error: null }}
/>
)
expect(screen.getByText(/Embedding… 50\/200/)).toBeInTheDocument()
})
it('calls onRemoveVault with vault path when remove button is clicked', () => {
const onRemoveVault = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={onRemoveVault} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByTestId('vault-menu-remove-Work Vault'))
expect(onRemoveVault).toHaveBeenCalledWith('/Users/luca/Work')
})
it('shows index ready when indexing is complete', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'complete', current: 1057, total: 1057, done: true, error: null }}
/>
)
expect(screen.getByText('Index ready')).toBeInTheDocument()
})
it('closes menu after removing a vault', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
fireEvent.click(screen.getByTestId('vault-menu-remove-Work Vault'))
expect(screen.queryByTestId('vault-menu-remove-Work Vault')).not.toBeInTheDocument()
})
it('shows error state in indexing badge', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'error', current: 0, total: 0, done: true, error: 'qmd not available' }}
/>
)
expect(screen.getByText('Index error')).toBeInTheDocument()
})
it('remove button has "Remove from list" title for accessibility', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onRemoveVault={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
expect(screen.getAllByTitle('Remove from list')).toHaveLength(2)
})
it('hides indexing badge when phase is idle', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
/>
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('does not show remove button when onRemoveVault is not provided', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
)
fireEvent.click(screen.getByTitle('Switch vault'))
expect(screen.queryByTestId('vault-menu-remove-Main Vault')).not.toBeInTheDocument()
})
it('hides indexing badge when no progress prop provided', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />
)
expect(screen.queryByTestId('status-indexing')).not.toBeInTheDocument()
})
it('shows installing phase in indexing badge', () => {
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'installing', current: 0, total: 0, done: false, error: null }}
/>
)
expect(screen.getByText('Installing search…')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { Package, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X } from 'lucide-react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X } from 'lucide-react'
import type { LastCommitInfo, SyncStatus } from '../types'
import type { IndexingProgress } from '../hooks/useIndexing'
import { openExternalUrl } from '../utils/url'
export interface VaultOption {
@@ -25,9 +26,11 @@ interface StatusBarProps {
conflictCount?: number
lastCommitInfo?: LastCommitInfo | null
onTriggerSync?: () => void
onOpenConflictResolver?: () => void
zoomLevel?: number
onZoomReset?: () => void
buildNumber?: string
indexingProgress?: IndexingProgress
onRemoveVault?: (path: string) => void
}
@@ -190,15 +193,17 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
)
}
function SyncBadge({ status, lastSyncTime, onTriggerSync }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void }) {
function SyncBadge({ status, lastSyncTime, onTriggerSync, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void; onOpenConflictResolver?: () => void }) {
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
const isSyncing = status === 'syncing'
const isConflict = status === 'conflict'
const handleClick = isConflict ? onOpenConflictResolver : onTriggerSync
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'}
onClick={handleClick}
style={{ ...ICON_STYLE, cursor: handleClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
title={isConflict ? 'Click to resolve conflicts' : isSyncing ? 'Syncing…' : 'Click to sync now'}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
@@ -206,18 +211,58 @@ function SyncBadge({ status, lastSyncTime, onTriggerSync }: { status: SyncStatus
)
}
function ConflictBadge({ count }: { count: number }) {
function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void }) {
if (count <= 0) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
<span
role="button"
onClick={onClick}
style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)', cursor: onClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title="Resolve merge conflicts"
onMouseEnter={onClick ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onClick ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
data-testid="status-conflict-count"
>
<AlertTriangle size={13} />{count} conflict{count > 1 ? 's' : ''}
</span>
</>
)
}
const INDEXING_LABELS: Record<string, string> = {
installing: 'Installing search…',
scanning: 'Indexing…',
embedding: 'Embedding…',
complete: 'Index ready',
error: 'Index error',
}
function IndexingBadge({ progress }: { progress: IndexingProgress }) {
if (progress.phase === 'idle') return null
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const showCount = progress.total > 0 && isActive
const displayText = showCount
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
: label
const color = progress.phase === 'error' ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
return (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color }} data-testid="status-indexing">
{isActive
? <Loader2 size={13} className="animate-spin" />
: <Search size={13} />
}
{displayText}
</span>
</>
)
}
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
if (count <= 0) return null
return (
@@ -236,7 +281,7 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber, onRemoveVault }: StatusBarProps) {
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, indexingProgress, onRemoveVault }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -250,13 +295,13 @@ 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>
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} />
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} onOpenConflictResolver={onOpenConflictResolver} />
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} />
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} />}
</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>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
{zoomLevel !== 100 && (
<span

View File

@@ -11,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, template: null, outgoingLinks: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
}
}

View File

@@ -31,6 +31,8 @@ interface AppCommandsConfig {
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
onResolveConflicts?: () => void
conflictCount?: number
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleDiff?: () => void
@@ -58,7 +60,6 @@ interface AppCommandsConfig {
onCreateType?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
isUpdating?: boolean
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
isGettingStartedHidden?: boolean
@@ -120,6 +121,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onSearch: config.onSearch,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
onCheckForUpdates: config.onCheckForUpdates,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
@@ -139,6 +141,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onArchiveNote: config.onArchiveNote,
onUnarchiveNote: config.onUnarchiveNote,
onCommitPush: config.onCommitPush,
onResolveConflicts: config.onResolveConflicts,
conflictCount: config.conflictCount,
onSetViewMode: config.onSetViewMode,
onToggleInspector: config.onToggleInspector,
onToggleDiff: config.onToggleDiff,
@@ -164,7 +168,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onCreateType: config.onCreateType,
onToggleAIChat: config.onToggleAIChat,
onCheckForUpdates: config.onCheckForUpdates,
isUpdating: config.isUpdating,
onRemoveActiveVault: config.onRemoveActiveVault,
onRestoreGettingStarted: config.onRestoreGettingStarted,
isGettingStartedHidden: config.isGettingStartedHidden,

View File

@@ -194,6 +194,28 @@ describe('useAutoSync', () => {
})
})
it('skips pull when paused via pausePull', async () => {
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
})
// Pause and clear mocks
act(() => { result.current.pausePull() })
mockInvokeFn.mockClear()
// Trigger sync while paused
act(() => { result.current.triggerSync() })
// Should not have called git_pull
const pullCalls = mockInvokeFn.mock.calls.filter((c: unknown[]) => c[0] === 'git_pull').length
expect(pullCalls).toBe(0)
// Resume
act(() => { result.current.resumePull() })
})
it('handles error status from git_pull result', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_last_commit_info') return Promise.resolve(null)

View File

@@ -23,6 +23,10 @@ export interface AutoSyncState {
conflictFiles: string[]
lastCommitInfo: LastCommitInfo | null
triggerSync: () => void
/** Pause auto-pull (e.g. while conflict resolver modal is open). */
pausePull: () => void
/** Resume auto-pull after pausing. */
resumePull: () => void
}
export function useAutoSync({
@@ -37,11 +41,12 @@ export function useAutoSync({
const [conflictFiles, setConflictFiles] = useState<string[]>([])
const [lastCommitInfo, setLastCommitInfo] = useState<LastCommitInfo | null>(null)
const syncingRef = useRef(false)
const pauseRef = useRef(false)
const callbacksRef = useRef({ onVaultUpdated, onConflict, onToast })
callbacksRef.current = { onVaultUpdated, onConflict, onToast }
const performPull = useCallback(async () => {
if (syncingRef.current) return
if (syncingRef.current || pauseRef.current) return
syncingRef.current = true
setSyncStatus('syncing')
@@ -95,5 +100,8 @@ export function useAutoSync({
return () => clearInterval(id)
}, [performPull, intervalMinutes])
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, triggerSync: performPull }
const pausePull = useCallback(() => { pauseRef.current = true }, [])
const resumePull = useCallback(() => { pauseRef.current = false }, [])
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, triggerSync: performPull, pausePull, resumePull }
}

View File

@@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})
@@ -369,20 +369,11 @@ describe('useCommandRegistry', () => {
expect(cmd!.keywords).toContain('version')
})
it('is enabled when not updating', () => {
const { result } = renderHook(() =>
useCommandRegistry(makeConfig({ isUpdating: false })),
)
it('is always enabled', () => {
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
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(() =>

View File

@@ -18,6 +18,7 @@ interface CommandRegistryConfig {
activeTabPath: string | null
entries: VaultEntry[]
modifiedCount: number
conflictCount?: number
onQuickOpen: () => void
onCreateNote: () => void
@@ -31,6 +32,7 @@ interface CommandRegistryConfig {
onArchiveNote: (path: string) => void
onUnarchiveNote: (path: string) => void
onCommitPush: () => void
onResolveConflicts?: () => void
onSetViewMode: (mode: ViewMode) => void
onToggleInspector: () => void
onToggleDiff?: () => void
@@ -38,7 +40,6 @@ interface CommandRegistryConfig {
onToggleAIChat?: () => void
activeNoteModified: boolean
onCheckForUpdates?: () => void
isUpdating?: boolean
onZoomIn: () => void
onZoomOut: () => void
onZoomReset: () => void
@@ -180,16 +181,16 @@ export function buildThemeCommands(
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
const {
activeTabPath, entries, modifiedCount,
activeTabPath, entries, modifiedCount, conflictCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onCheckForUpdates, isUpdating,
onCheckForUpdates,
onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
} = config
@@ -236,6 +237,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
// Git
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: (conflictCount ?? 0) > 0, execute: () => onResolveConflicts?.() },
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
// View
@@ -249,7 +251,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: !isUpdating, execute: () => onCheckForUpdates?.() },
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -257,15 +259,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
return cmds
}, [
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, conflictCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
onCheckForUpdates, isUpdating,
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect, onOpenDailyNote, onCloseTab,
onGoBack, onGoForward, canGoBack, canGoForward,
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
])
}

View File

@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useConflictResolver } from './useConflictResolver'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
describe('useConflictResolver', () => {
const onResolved = vi.fn()
const onToast = vi.fn()
const onOpenFile = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockResolvedValue(undefined)
})
function renderResolver(files: string[] = ['note.md', 'plan.md']) {
const hook = renderHook(() =>
useConflictResolver({
vaultPath: '/vault',
onResolved,
onToast,
onOpenFile,
}),
)
// Initialize files
act(() => { hook.result.current.initFiles(files) })
return hook
}
it('initializes file states from conflict files', () => {
const { result } = renderResolver()
expect(result.current.fileStates).toHaveLength(2)
expect(result.current.fileStates[0]).toEqual({ file: 'note.md', resolution: null, resolving: false })
expect(result.current.fileStates[1]).toEqual({ file: 'plan.md', resolution: null, resolving: false })
expect(result.current.allResolved).toBe(false)
})
it('resolves a file with ours strategy', async () => {
const { result } = renderResolver()
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
expect(mockInvokeFn).toHaveBeenCalledWith('git_resolve_conflict', {
vaultPath: '/vault', file: 'note.md', strategy: 'ours',
})
expect(result.current.fileStates[0].resolution).toBe('ours')
expect(result.current.allResolved).toBe(false) // plan.md still unresolved
})
it('resolves a file with theirs strategy', async () => {
const { result } = renderResolver()
await act(async () => {
await result.current.resolveFile('plan.md', 'theirs')
})
expect(mockInvokeFn).toHaveBeenCalledWith('git_resolve_conflict', {
vaultPath: '/vault', file: 'plan.md', strategy: 'theirs',
})
expect(result.current.fileStates[1].resolution).toBe('theirs')
})
it('marks allResolved when all files are resolved', async () => {
const { result } = renderResolver()
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
expect(result.current.allResolved).toBe(false)
await act(async () => {
await result.current.resolveFile('plan.md', 'theirs')
})
expect(result.current.allResolved).toBe(true)
})
it('sets manual resolution when opening in editor', () => {
const { result } = renderResolver()
act(() => { result.current.openInEditor('note.md') })
expect(onOpenFile).toHaveBeenCalledWith('note.md')
expect(result.current.fileStates[0].resolution).toBe('manual')
})
it('commits resolution and calls onResolved', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'git_commit_conflict_resolution') return Promise.resolve('Committed')
return Promise.resolve(undefined)
})
const { result } = renderResolver(['note.md'])
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
await act(async () => {
await result.current.commitResolution()
})
expect(mockInvokeFn).toHaveBeenCalledWith('git_commit_conflict_resolution', { vaultPath: '/vault' })
expect(onResolved).toHaveBeenCalled()
expect(onToast).toHaveBeenCalledWith('Conflicts resolved — sync resumed')
})
it('does not commit when not all files are resolved', async () => {
const { result } = renderResolver()
await act(async () => {
await result.current.commitResolution()
})
expect(mockInvokeFn).not.toHaveBeenCalledWith('git_commit_conflict_resolution', expect.anything())
})
it('shows error when resolve fails', async () => {
mockInvokeFn.mockRejectedValueOnce(new Error('git checkout failed'))
const { result } = renderResolver()
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
expect(result.current.error).toContain('Failed to resolve note.md')
expect(result.current.fileStates[0].resolution).toBeNull()
})
it('shows error when commit fails', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'git_commit_conflict_resolution') return Promise.reject(new Error('user.email not set'))
return Promise.resolve(undefined)
})
const { result } = renderResolver(['note.md'])
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
await act(async () => {
await result.current.commitResolution()
})
expect(result.current.error).toContain('Commit failed')
expect(onResolved).not.toHaveBeenCalled()
})
it('resets state on initFiles', async () => {
const { result } = renderResolver(['note.md'])
await act(async () => {
await result.current.resolveFile('note.md', 'ours')
})
expect(result.current.fileStates[0].resolution).toBe('ours')
act(() => { result.current.initFiles(['other.md']) })
expect(result.current.fileStates).toHaveLength(1)
expect(result.current.fileStates[0]).toEqual({ file: 'other.md', resolution: null, resolving: false })
expect(result.current.error).toBeNull()
})
})

View File

@@ -0,0 +1,96 @@
import { useCallback, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
export type FileResolution = 'ours' | 'theirs' | 'manual' | null
export interface ConflictFileState {
file: string
resolution: FileResolution
resolving: boolean
}
interface UseConflictResolverConfig {
vaultPath: string
onResolved: () => void
onToast: (msg: string) => void
onOpenFile: (relativePath: string) => void
}
export function useConflictResolver({
vaultPath,
onResolved,
onToast,
onOpenFile,
}: UseConflictResolverConfig) {
const [fileStates, setFileStates] = useState<ConflictFileState[]>([])
const [committing, setCommitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const initFiles = useCallback((files: string[]) => {
setFileStates(files.map(file => ({ file, resolution: null, resolving: false })))
setError(null)
setCommitting(false)
}, [])
const resolveFile = useCallback(async (file: string, strategy: 'ours' | 'theirs') => {
setFileStates(prev => prev.map(f =>
f.file === file ? { ...f, resolving: true } : f
))
setError(null)
try {
await tauriCall<void>('git_resolve_conflict', { vaultPath, file, strategy })
setFileStates(prev => prev.map(f =>
f.file === file ? { ...f, resolution: strategy, resolving: false } : f
))
} catch (err) {
setFileStates(prev => prev.map(f =>
f.file === file ? { ...f, resolving: false } : f
))
setError(`Failed to resolve ${file}: ${err}`)
}
}, [vaultPath])
const openInEditor = useCallback((file: string) => {
onOpenFile(file)
setFileStates(prev => prev.map(f =>
f.file === file ? { ...f, resolution: 'manual' } : f
))
}, [onOpenFile])
const allResolved = fileStates.length > 0 && fileStates.every(f => f.resolution !== null)
const anyResolving = fileStates.some(f => f.resolving)
const commitResolution = useCallback(async () => {
if (!allResolved || committing) return
setCommitting(true)
setError(null)
try {
await tauriCall<string>('git_commit_conflict_resolution', { vaultPath })
onResolved()
onToast('Conflicts resolved — sync resumed')
} catch (err) {
setError(`Commit failed: ${err}`)
} finally {
setCommitting(false)
}
}, [vaultPath, allResolved, committing, onResolved, onToast])
return {
fileStates,
committing,
error,
allResolved,
anyResolving,
initFiles,
resolveFile,
openInEditor,
commitResolution,
}
}

View File

@@ -8,6 +8,7 @@ export function useDialogs() {
const [showSettings, setShowSettings] = useState(false)
const [showGitHubVault, setShowGitHubVault] = useState(false)
const [showSearch, setShowSearch] = useState(false)
const [showConflictResolver, setShowConflictResolver] = useState(false)
const openCreateType = useCallback(() => setShowCreateTypeDialog(true), [])
const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), [])
@@ -22,6 +23,8 @@ export function useDialogs() {
const toggleAIChat = useCallback(() => setShowAIChat((c) => !c), [])
const openSearch = useCallback(() => setShowSearch(true), [])
const closeSearch = useCallback(() => setShowSearch(false), [])
const openConflictResolver = useCallback(() => setShowConflictResolver(true), [])
const closeConflictResolver = useCallback(() => setShowConflictResolver(false), [])
return {
showCreateTypeDialog, openCreateType, closeCreateType,
@@ -31,5 +34,6 @@ export function useDialogs() {
showSettings, openSettings, closeSettings,
showGitHubVault, openGitHubVault, closeGitHubVault,
showSearch, openSearch, closeSearch,
showConflictResolver, openConflictResolver, closeConflictResolver,
}
}

View File

@@ -0,0 +1,32 @@
import { useCallback, useRef } from 'react'
import { useEditorSave } from './useEditorSave'
import { extractOutgoingLinks } from '../utils/wikilinks'
import type { VaultEntry } from '../types'
export function useEditorSaveWithLinks(config: {
updateContent: (path: string, content: string) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
setToastMessage: (msg: string | null) => void
onAfterSave: () => void
onNotePersisted?: (path: string) => void
}) {
const { updateContent, updateEntry } = config
const saveContent = useCallback((path: string, content: string) => {
updateContent(path, content)
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
}, [updateContent, updateEntry])
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
const { handleContentChange: rawOnChange } = editor
const prevLinksKeyRef = useRef('')
const handleContentChange = useCallback((path: string, content: string) => {
rawOnChange(path, content)
const links = extractOutgoingLinks(content)
const key = links.join('\0')
if (key !== prevLinksKeyRef.current) {
prevLinksKeyRef.current = key
updateEntry(path, { outgoingLinks: links })
}
}, [rawOnChange, updateEntry])
return { ...editor, handleContentChange }
}

View File

@@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,

View File

@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useIndexing } from './useIndexing'
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn().mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
})
}
if (cmd === 'start_indexing') return Promise.resolve(null)
if (cmd === 'trigger_incremental_index') return Promise.resolve(null)
return Promise.resolve(null)
}),
}))
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
describe('useIndexing', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts with idle progress', () => {
const { result } = renderHook(() => useIndexing('/vault'))
expect(result.current.progress.phase).toBe('idle')
expect(result.current.progress.done).toBe(false)
})
it('checks index status on mount', async () => {
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' })
})
})
it('does not start indexing when collection exists and no pending embeds', async () => {
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('get_index_status', { vaultPath: '/vault' })
})
expect(mockInvoke).not.toHaveBeenCalledWith('start_indexing', expect.anything())
})
it('starts indexing when collection is missing', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
expect(result.current.progress.phase).not.toBe('idle')
})
it('starts indexing when qmd is not installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
return Promise.resolve(null)
})
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
})
it('starts indexing when pending embeds exist', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 20,
})
}
return Promise.resolve(null)
})
renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith('start_indexing', { vaultPath: '/vault' })
})
})
it('triggerIncrementalIndex calls the command', async () => {
const { result } = renderHook(() => useIndexing('/vault'))
await act(async () => {
await result.current.triggerIncrementalIndex()
})
expect(mockInvoke).toHaveBeenCalledWith('trigger_incremental_index', { vaultPath: '/vault' })
})
it('handles index status check failure gracefully', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') return Promise.reject(new Error('network error'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
// Should remain idle — not crash
await waitFor(() => {
expect(result.current.progress.phase).toBe('idle')
})
})
it('sets error phase when start_indexing fails', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'get_index_status') {
return Promise.resolve({
available: false,
qmd_installed: false,
collection_exists: false,
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
})
}
if (cmd === 'start_indexing') return Promise.reject(new Error('install failed'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useIndexing('/vault'))
await waitFor(() => {
expect(result.current.progress.phase).toBe('error')
})
expect(result.current.progress.error).toContain('install failed')
})
})

116
src/hooks/useIndexing.ts Normal file
View File

@@ -0,0 +1,116 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export interface IndexingProgress {
phase: 'idle' | 'installing' | 'scanning' | 'embedding' | 'complete' | 'error'
current: number
total: number
done: boolean
error: string | null
}
interface IndexStatus {
available: boolean
qmd_installed: boolean
collection_exists: boolean
indexed_count: number
embedded_count: number
pending_embed: number
}
const IDLE: IndexingProgress = { phase: 'idle', current: 0, total: 0, done: false, error: null }
function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
export function useIndexing(vaultPath: string) {
const [progress, setProgress] = useState<IndexingProgress>(IDLE)
const indexingRef = useRef(false)
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
// Listen for progress events from Rust
useEffect(() => {
if (!isTauri()) return
let cleanup: (() => void) | null = null
import('@tauri-apps/api/event').then(({ listen }) => {
const unlisten = listen<IndexingProgress>('indexing-progress', (event) => {
setProgress(event.payload)
if (event.payload.done) {
indexingRef.current = false
}
})
cleanup = () => { unlisten.then(fn => fn()) }
})
return () => { cleanup?.() }
}, [])
// Check index status and auto-trigger indexing on vault open
useEffect(() => {
if (!vaultPath) return
let cancelled = false
async function checkAndIndex() {
try {
const status = await invokeCmd<IndexStatus>('get_index_status', { vaultPath })
if (cancelled) return
// If qmd not installed or no collection or pending embeds, trigger indexing
const needsIndexing = !status.qmd_installed || !status.collection_exists || status.pending_embed > 0
if (needsIndexing && !indexingRef.current) {
indexingRef.current = true
setProgress({
phase: 'scanning',
current: 0,
total: status.indexed_count,
done: false,
error: null,
})
// Fire and forget — progress updates come via events
invokeCmd('start_indexing', { vaultPath }).catch((err) => {
if (!cancelled) {
setProgress({
phase: 'error',
current: 0,
total: 0,
done: true,
error: String(err),
})
indexingRef.current = false
}
})
}
} catch {
// get_index_status failed — likely qmd not available, non-fatal
}
}
checkAndIndex()
return () => { cancelled = true }
}, [vaultPath])
// Auto-dismiss the "complete" status after 5 seconds
useEffect(() => {
if (progress.phase === 'complete') {
const timer = setTimeout(() => setProgress(IDLE), 5000)
return () => clearTimeout(timer)
}
}, [progress.phase])
const triggerIncrementalIndex = useCallback(async () => {
if (indexingRef.current) return
try {
await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current })
} catch {
// Incremental update failure is non-fatal
}
}, [])
return { progress, triggerIncrementalIndex }
}

View File

@@ -30,7 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
...overrides,

View File

@@ -19,6 +19,7 @@ function makeHandlers(): MenuEventHandlers {
onSearch: vi.fn(),
onGoBack: vi.fn(),
onGoForward: vi.fn(),
onCheckForUpdates: vi.fn(),
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
activeTabPath: '/vault/test.md',
@@ -161,6 +162,12 @@ describe('dispatchMenuEvent', () => {
expect(h.onGoForward).toHaveBeenCalled()
})
it('app-check-for-updates triggers check for updates', () => {
const h = makeHandlers()
dispatchMenuEvent('app-check-for-updates', h)
expect(h.onCheckForUpdates).toHaveBeenCalled()
})
it('unknown event ID does nothing', () => {
const h = makeHandlers()
dispatchMenuEvent('unknown-event', h)

View File

@@ -19,6 +19,7 @@ export interface MenuEventHandlers {
onSearch: () => void
onGoBack?: () => void
onGoForward?: () => void
onCheckForUpdates?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
@@ -58,6 +59,7 @@ function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
function dispatchOptionalEvent(id: string, h: MenuEventHandlers): boolean {
if (id === 'view-go-back') { h.onGoBack?.(); return true }
if (id === 'view-go-forward') { h.onGoForward?.(); return true }
if (id === 'app-check-for-updates') { h.onCheckForUpdates?.(); return true }
return false
}

View File

@@ -0,0 +1,52 @@
import { useEffect } from 'react'
/**
* Registers mouse button 3/4 (back/forward) and macOS trackpad two-finger
* horizontal swipe gestures for navigation.
*/
export function useNavigationGestures({
onGoBack,
onGoForward,
}: {
onGoBack: () => void
onGoForward: () => void
}) {
useEffect(() => {
const handleMouseBack = (e: MouseEvent) => {
if (e.button === 3) { e.preventDefault(); onGoBack() }
if (e.button === 4) { e.preventDefault(); onGoForward() }
}
window.addEventListener('mouseup', handleMouseBack)
// Trackpad swipe: accumulate horizontal wheel delta and trigger on threshold
let accumulatedDeltaX = 0
let resetTimer: ReturnType<typeof setTimeout> | null = null
const SWIPE_THRESHOLD = 120
const handleWheel = (e: WheelEvent) => {
// Only handle horizontal-dominant gestures (trackpad swipe)
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
if (e.ctrlKey || e.metaKey) return // ignore pinch-zoom
accumulatedDeltaX += e.deltaX
if (resetTimer) clearTimeout(resetTimer)
resetTimer = setTimeout(() => { accumulatedDeltaX = 0 }, 300)
if (accumulatedDeltaX > SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
onGoForward()
} else if (accumulatedDeltaX < -SWIPE_THRESHOLD) {
accumulatedDeltaX = 0
onGoBack()
}
}
window.addEventListener('wheel', handleWheel, { passive: true })
return () => {
window.removeEventListener('mouseup', handleMouseBack)
window.removeEventListener('wheel', handleWheel)
if (resetTimer) clearTimeout(resetTimer)
}
}, [onGoBack, onGoForward])
}

View File

@@ -59,7 +59,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
color: null,
order: null,
outgoingLinks: [],
template: null,
template: null, sort: null,
...overrides,
})

View File

@@ -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, template: null, properties: {},
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, properties: {},
}
}
@@ -148,7 +148,7 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
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 },
template: { template: null }, sort: { sort: null },
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
@@ -166,6 +166,7 @@ export function frontmatterToEntryPatch(
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
order: { order: typeof value === 'number' ? value : null },
template: { template: str },
sort: { sort: str },
}
return updates[k] ?? {}
}

View File

@@ -22,7 +22,7 @@ function makeEntry(path: string, title: string): VaultEntry {
fileSize: 100,
color: null,
icon: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
relationships: {},
}

View File

@@ -32,7 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
})

View File

@@ -56,7 +56,7 @@ function makeThemeEntry(path: string, title: string): VaultEntry {
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
}
@@ -84,7 +84,7 @@ vi.mock('../mock-tauri', () => ({
mockInvoke: (cmd: string, args?: Record<string, unknown>) => mockInvokeFn(cmd, args),
}))
const { useThemeManager, extractCssVars } = await import('./useThemeManager')
const { useThemeManager, extractCssVars, isColorDark } = await import('./useThemeManager')
describe('extractCssVars', () => {
it('extracts color variables from frontmatter', () => {
@@ -101,6 +101,26 @@ describe('extractCssVars', () => {
})
})
describe('isColorDark', () => {
it('identifies dark colors', () => {
expect(isColorDark('#000000')).toBe(true)
expect(isColorDark('#0f0f1a')).toBe(true)
expect(isColorDark('#1a1a2e')).toBe(true)
})
it('identifies light colors', () => {
expect(isColorDark('#FFFFFF')).toBe(false)
expect(isColorDark('#F7F6F3')).toBe(false)
expect(isColorDark('#E0E0E0')).toBe(false)
})
it('returns false for invalid hex', () => {
expect(isColorDark('')).toBe(false)
expect(isColorDark('#abc')).toBe(false)
expect(isColorDark('red')).toBe(false)
})
})
describe('useThemeManager', () => {
const entries = [defaultEntry, darkEntry]
const allContent: Record<string, string> = {}
@@ -346,4 +366,116 @@ describe('useThemeManager', () => {
const afterCalls = mockInvokeFn.mock.calls.filter(c => c[0] === 'get_vault_settings').length
expect(afterCalls).toBe(initialCalls + 1)
})
it('clears stale theme ID that does not match any known theme', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_vault_settings') return { theme: 'untitled-2' }
if (cmd === 'set_active_theme') return null
return null
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
// Stale ID "untitled-2" doesn't match any theme path — should be cleared
await waitFor(() => {
expect(result.current.activeThemeId).toBeNull()
})
expect(mockInvokeFn).toHaveBeenCalledWith('set_active_theme', {
vaultPath: '/vault', themeId: null,
})
})
it('sets color-scheme to light for light theme', async () => {
const { result } = renderHook(() =>
useThemeManager('/vault', entries, allContent)
)
await waitFor(() => {
expect(result.current.activeTheme).not.toBeNull()
})
const root = document.documentElement
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
expect(root.dataset.themeMode).toBe('light')
})
it('sets color-scheme to dark for dark theme', async () => {
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)
})
await waitFor(() => {
expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark')
})
expect(document.documentElement.dataset.themeMode).toBe('dark')
})
it('populates theme colors from allContent when available', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
[THEME_PATH_DARK]: DARK_THEME_CONTENT,
}
const { result } = renderHook(() =>
useThemeManager('/vault', entries, contentWithColors)
)
await waitFor(() => {
expect(result.current.themes).toHaveLength(2)
})
const defaultTheme = result.current.themes.find(t => t.id === THEME_PATH_DEFAULT)
expect(defaultTheme?.colors.background).toBe('#FFFFFF')
expect(defaultTheme?.colors.primary).toBe('#155DFF')
const darkTheme = result.current.themes.find(t => t.id === THEME_PATH_DARK)
expect(darkTheme?.colors.background).toBe('#0f0f1a')
})
it('isDark detects dark theme from cached content', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
[THEME_PATH_DARK]: DARK_THEME_CONTENT,
}
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_vault_settings') return { theme: THEME_PATH_DARK }
if (cmd === 'set_active_theme') return null
if (cmd === 'get_note_content') return DARK_THEME_CONTENT
return null
})
const { result } = renderHook(() =>
useThemeManager('/vault', entries, contentWithColors)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DARK)
})
await waitFor(() => {
expect(result.current.isDark).toBe(true)
})
})
it('isDark is false for light theme', async () => {
const contentWithColors = {
[THEME_PATH_DEFAULT]: DEFAULT_THEME_CONTENT,
}
const { result } = renderHook(() =>
useThemeManager('/vault', entries, contentWithColors)
)
await waitFor(() => {
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
})
// Light theme isDark should be false (default state is false, so this is stable)
expect(result.current.isDark).toBe(false)
})
})

View File

@@ -41,11 +41,50 @@ export function extractCssVars(content: string): Record<string, string> {
return vars
}
/** Extract bare colors (without -- prefix) for ThemeFile.colors from content. */
function extractColorsFromContent(content: string): Record<string, string> {
const fm = parseFrontmatter(content)
const colors: Record<string, string> = {}
for (const [key, value] of Object.entries(fm)) {
if (NON_THEME_KEYS.has(key)) continue
if (typeof value === 'string' && value.startsWith('#')) {
colors[key] = value
}
}
return colors
}
/** Check if a hex color is perceptually dark (luminance < 0.5). */
export function isColorDark(hex: string): boolean {
if (!hex.startsWith('#') || hex.length < 7) return false
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
}
/** Update color-scheme and data-theme-mode on document root based on --background. */
function updateColorScheme(vars: Record<string, string>): void {
const bg = vars['--background']
if (!bg) return
const dark = isColorDark(bg)
const root = document.documentElement
root.style.setProperty('color-scheme', dark ? 'dark' : 'light')
root.dataset.themeMode = dark ? 'dark' : 'light'
}
function clearColorScheme(): void {
const root = document.documentElement
root.style.removeProperty('color-scheme')
delete root.dataset.themeMode
}
function applyVarsToDom(vars: Record<string, string>): void {
const root = document.documentElement
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value)
}
updateColorScheme(vars)
}
function clearVarsFromDom(vars: Record<string, string>): void {
@@ -53,16 +92,17 @@ function clearVarsFromDom(vars: Record<string, string>): void {
for (const key of Object.keys(vars)) {
root.style.removeProperty(key)
}
clearColorScheme()
}
/** Build a ThemeFile descriptor from a vault entry (metadata only). */
function entryToThemeFile(entry: VaultEntry): ThemeFile {
/** Build a ThemeFile descriptor from a vault entry, enriched with content colors. */
function entryToThemeFile(entry: VaultEntry, content: string | undefined): ThemeFile {
return {
id: entry.path,
name: entry.title,
description: '',
path: entry.path,
colors: {},
colors: content ? extractColorsFromContent(content) : {},
typography: {},
spacing: {},
}
@@ -112,15 +152,17 @@ function useThemeApplier(
cachedContent: string | undefined,
) {
const appliedVarsRef = useRef<Record<string, string>>({})
const [isDark, setIsDark] = useState(false)
const apply = useCallback((content: string) => {
const applyDom = useCallback((content: string) => {
const newVars = extractCssVars(content)
clearVarsFromDom(appliedVarsRef.current)
applyVarsToDom(newVars)
appliedVarsRef.current = newVars
return newVars
}, [])
const clear = useCallback(() => {
const clearDom = useCallback(() => {
clearVarsFromDom(appliedVarsRef.current)
appliedVarsRef.current = {}
}, [])
@@ -128,14 +170,25 @@ function useThemeApplier(
// 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 }
if (!activeThemeId) {
clearDom()
setIsDark(false) // eslint-disable-line react-hooks/set-state-in-effect -- sync dark mode with cleared theme
return
}
if (cachedContent) {
const vars = applyDom(cachedContent)
setIsDark(isColorDark(vars['--background'] ?? ''))
return
}
tauriCall<string>('get_note_content', { path: activeThemeId })
.then(apply)
.catch(clear)
}, [activeThemeId, cachedContent, apply, clear])
.then(content => {
const vars = applyDom(content)
setIsDark(isColorDark(vars['--background'] ?? ''))
})
.catch(() => { clearDom(); setIsDark(false) })
}, [activeThemeId, cachedContent, applyDom, clearDom])
return { clear }
return { clearDom, isDark }
}
export function useThemeManager(
@@ -145,11 +198,17 @@ export function useThemeManager(
): ThemeManager {
const { activeThemeId, setActiveThemeId, reload } = useThemeSetting(vaultPath)
const cachedThemeContent = activeThemeId ? allContent[activeThemeId] : undefined
const { clear: clearTheme } = useThemeApplier(activeThemeId, cachedThemeContent)
const { clearDom: clearTheme, isDark } = useThemeApplier(activeThemeId, cachedThemeContent)
// Track IDs set by user actions (switchTheme/createTheme) so the stale-ID
// cleanup doesn't clear a newly-created theme that isn't in entries yet.
const userSetIdRef = useRef<string | null>(null)
const themes = useMemo(
() => entries.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived).map(entryToThemeFile),
[entries],
() => entries
.filter(e => e.isA === 'Theme' && !e.trashed && !e.archived)
.map(e => entryToThemeFile(e, allContent[e.path])),
[entries, allContent],
)
const activeTheme = useMemo(
@@ -157,6 +216,21 @@ export function useThemeManager(
[themes, activeThemeId],
)
// If active theme ID doesn't match any known theme (e.g. stale ID from old
// JSON-based theme system), clear it so the app doesn't try to load a
// non-existent path. Skip IDs just set by switchTheme/createTheme — the
// entry may not have appeared in `entries` yet.
useEffect(() => {
if (!activeThemeId || themes.length === 0) return
if (activeThemeId === userSetIdRef.current) return
const isKnown = themes.some(t => t.id === activeThemeId)
if (!isKnown) {
clearTheme()
setActiveThemeId(null)
if (vaultPath) tauriCall('set_active_theme', { vaultPath, themeId: null }).catch(() => {})
}
}, [activeThemeId, themes, clearTheme, vaultPath, setActiveThemeId])
// If active theme is trashed or archived: clear CSS vars and fall back to no theme
useEffect(() => {
if (!activeThemeId) return
@@ -171,6 +245,7 @@ export function useThemeManager(
if (!vaultPath) return
try {
await tauriCall<null>('set_active_theme', { vaultPath, themeId })
userSetIdRef.current = themeId
setActiveThemeId(themeId)
} catch (err) { console.error('Failed to switch theme:', err) }
}, [vaultPath, setActiveThemeId])
@@ -180,6 +255,7 @@ export function useThemeManager(
try {
const path = await tauriCall<string>('create_vault_theme', { vaultPath, name: name ?? null })
await tauriCall<null>('set_active_theme', { vaultPath, themeId: path })
userSetIdRef.current = path
setActiveThemeId(path)
return path
} catch (err) { console.error('Failed to create theme:', err); return '' }
@@ -187,17 +263,5 @@ export function useThemeManager(
const reloadThemes = useCallback(async () => { await reload() }, [reload])
// Determine if the active theme is dark by checking --background CSS variable
const isDark = useMemo(() => {
if (!activeThemeId || !cachedThemeContent) return false
const vars = extractCssVars(cachedThemeContent)
const bg = vars['--background'] ?? ''
if (!bg.startsWith('#') || bg.length < 7) return false
const r = parseInt(bg.slice(1, 3), 16)
const g = parseInt(bg.slice(3, 5), 16)
const b = parseInt(bg.slice(5, 7), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
}, [activeThemeId, cachedThemeContent])
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes }
}

View File

@@ -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, template: null, outgoingLinks: [],
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
},
]

View File

@@ -172,8 +172,8 @@ export function useVaultLoader(vaultPath: string) {
const reloadVault = useCallback(
() => loadVaultData(vaultPath)
.then((data) => { setEntries(data.entries); setAllContent((prev) => ({ ...prev, ...data.allContent })); loadModifiedFiles() })
.catch((err) => console.warn('Vault reload failed:', err)),
.then((data) => { setEntries(data.entries); setAllContent((prev) => ({ ...prev, ...data.allContent })); loadModifiedFiles(); return data.entries })
.catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] }),
[vaultPath, loadModifiedFiles],
)

View File

@@ -36,7 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
properties: { Priority: 'High', 'Due date': '2026-06-15' },
},
@@ -73,7 +73,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly' },
},
@@ -104,7 +104,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['person/matteo-cellini'],
properties: {},
},
@@ -135,7 +135,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -166,7 +166,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
properties: {},
},
@@ -198,7 +198,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
properties: { Priority: 'Low', 'Due date': '2026-03-01' },
},
@@ -230,7 +230,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
properties: { Priority: 'Medium', Rating: 4 },
},
@@ -261,7 +261,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['project/26q1-laputa-app'],
properties: {},
},
@@ -291,7 +291,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: { Company: 'Acme Corp', Role: 'Engineering Lead' },
},
@@ -321,7 +321,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: { Company: 'TechStart', Role: 'Product Manager' },
},
@@ -351,7 +351,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -381,7 +381,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -412,7 +412,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
properties: {},
},
@@ -443,7 +443,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -474,7 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -505,7 +505,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -537,7 +537,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
properties: {},
},
@@ -568,7 +568,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -597,7 +597,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 0,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -625,7 +625,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 1,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -653,7 +653,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 2,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -681,7 +681,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 3,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -709,7 +709,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 4,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -737,7 +737,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 5,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -765,7 +765,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 6,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -793,7 +793,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 7,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -821,7 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 8,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -850,7 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'orange',
order: 9,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -878,7 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'green',
order: 10,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -909,7 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 },
},
@@ -939,7 +939,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 },
},
@@ -971,7 +971,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -1001,7 +1001,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -1032,7 +1032,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -1055,7 +1055,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 120,
@@ -1086,7 +1086,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 90,
@@ -1151,7 +1151,7 @@ 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,
template: null, sort: null,
properties: {},
})
}
@@ -1184,7 +1184,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -1212,7 +1212,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
@@ -1240,7 +1240,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},

View File

@@ -264,6 +264,9 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0 }),
start_indexing: () => null,
trigger_incremental_index: () => null,
list_themes: (): ThemeFile[] => [...mockThemes],
get_theme: (args: { themeId: string }): ThemeFile => {
const t = mockThemes.find(t => t.id === args.themeId)

View File

@@ -29,6 +29,8 @@ export interface VaultEntry {
sidebarLabel: string | null
/** Markdown template for Type entries. Pre-fills new notes created with this type. */
template: string | null
/** Default sort preference for the note list of this Type. Format: "option:direction". */
sort: string | null
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
/** Custom scalar frontmatter properties (non-relationship, non-structural). */

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection } from './noteListHelpers'
import { formatSubtitle, formatSearchSubtitle, relativeDate, buildRelationshipGroups, getSortComparator, extractSortableProperties, getSortOptionLabel, getDefaultDirection, parseSortConfig, serializeSortConfig } from './noteListHelpers'
import type { VaultEntry } from '../types'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
@@ -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, template: null, outgoingLinks: [],
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
...overrides,
}
}
@@ -435,3 +435,53 @@ describe('getDefaultDirection', () => {
expect(getDefaultDirection('property:Priority')).toBe('asc')
})
})
describe('serializeSortConfig', () => {
it('serializes a built-in sort config', () => {
expect(serializeSortConfig({ option: 'modified', direction: 'desc' })).toBe('modified:desc')
expect(serializeSortConfig({ option: 'title', direction: 'asc' })).toBe('title:asc')
})
it('serializes a custom property sort config', () => {
expect(serializeSortConfig({ option: 'property:Priority', direction: 'asc' })).toBe('property:Priority:asc')
})
})
describe('parseSortConfig', () => {
it('parses a built-in sort config', () => {
expect(parseSortConfig('modified:desc')).toEqual({ option: 'modified', direction: 'desc' })
expect(parseSortConfig('title:asc')).toEqual({ option: 'title', direction: 'asc' })
})
it('parses a custom property sort config with colon in option', () => {
expect(parseSortConfig('property:Priority:asc')).toEqual({ option: 'property:Priority', direction: 'asc' })
})
it('returns null for null/undefined input', () => {
expect(parseSortConfig(null)).toBeNull()
expect(parseSortConfig(undefined)).toBeNull()
})
it('returns null for empty string', () => {
expect(parseSortConfig('')).toBeNull()
})
it('returns null for invalid direction', () => {
expect(parseSortConfig('modified:up')).toBeNull()
})
it('returns null for string without colon', () => {
expect(parseSortConfig('modified')).toBeNull()
})
it('roundtrips correctly', () => {
const configs = [
{ option: 'modified' as const, direction: 'desc' as const },
{ option: 'title' as const, direction: 'asc' as const },
{ option: 'property:Due date' as const, direction: 'desc' as const },
]
for (const config of configs) {
expect(parseSortConfig(serializeSortConfig(config))).toEqual(config)
}
})
})

View File

@@ -183,6 +183,23 @@ export function getSortComparator(option: SortOption, direction?: SortDirection)
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
/** Serialize a SortConfig to the string format stored in type frontmatter: "option:direction". */
export function serializeSortConfig(config: SortConfig): string {
return `${config.option}:${config.direction}`
}
/** Parse a frontmatter sort string ("option:direction") back to SortConfig. */
export function parseSortConfig(raw: string | null | undefined): SortConfig | null {
if (!raw) return null
// Format: "option:direction" where option itself can contain ":" (e.g. "property:Priority:asc")
const lastColon = raw.lastIndexOf(':')
if (lastColon <= 0) return null
const dir = raw.slice(lastColon + 1)
if (dir !== 'asc' && dir !== 'desc') return null
const option = raw.slice(0, lastColon) as SortOption
return { option, direction: dir }
}
export function loadSortPreferences(): Record<string, SortConfig> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
@@ -210,6 +227,21 @@ export function saveSortPreferences(prefs: Record<string, SortConfig>) {
} catch { /* ignore */ }
}
/** Remove the `__list__` key from localStorage sort preferences (used during migration). */
export function clearListSortFromLocalStorage(): void {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
if (!raw) return
const parsed = JSON.parse(raw)
delete parsed['__list__']
if (Object.keys(parsed).length === 0) {
localStorage.removeItem(SORT_STORAGE_KEY)
} else {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(parsed))
}
} catch { /* ignore */ }
}
function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[], allContent: Record<string, string>): VaultEntry[] {
const stem = entity.filename.replace(/\.md$/, '')
const pathStem = entity.path.replace(/^.*\/Laputa\//, '').replace(/\.md$/, '')

View File

@@ -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, template: null, outgoingLinks: [],
order: null, template: null, sort: null, outgoingLinks: [],
...overrides,
}
}

View File

@@ -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, template: null, outgoingLinks: [],
icon: null, color: null, order: null, template: null, sort: null, outgoingLinks: [],
}
describe('buildTypeEntryMap', () => {

View File

@@ -26,7 +26,7 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
icon: null,
color: null,
order: null,
template: null,
template: null, sort: null,
outgoingLinks: [],
...overrides,
}