Compare commits

...

11 Commits

Author SHA1 Message Date
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
54 changed files with 2374 additions and 308 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

@@ -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

@@ -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'
@@ -28,14 +27,18 @@ import { useGitHistory } from './hooks/useGitHistory'
import { useUpdater } 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)
@@ -321,7 +305,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(),
@@ -399,7 +386,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 +439,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

@@ -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, Sparkles, 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,10 +295,11 @@ 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>

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
@@ -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,

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,
})

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
@@ -180,10 +182,10 @@ 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,
@@ -236,6 +238,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
@@ -257,15 +260,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,
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCheckForUpdates, isUpdating,
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

@@ -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

@@ -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,
}