Compare commits
18 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17eeac75cd | ||
|
|
2dad764ea7 | ||
|
|
c3fa296b99 | ||
|
|
6370b66e05 | ||
|
|
1ec27dd264 | ||
|
|
10e6d7b366 | ||
|
|
0c87e51037 | ||
|
|
7df1961172 | ||
|
|
ec74f86d53 | ||
|
|
d86dfbfcb6 | ||
|
|
e77208ec34 | ||
|
|
818707603e | ||
|
|
fa3f9adccc | ||
|
|
bf6312000e | ||
|
|
832181b9a9 | ||
|
|
bc011692cd | ||
|
|
0604a13cdd | ||
|
|
eb77607401 |
1
design/search-bundle-qmd.pen
Normal file
1
design/search-bundle-qmd.pen
Normal 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":{}}
|
||||
1
design/sync-conflict-resolution.pen
Normal file
1
design/sync-conflict-resolution.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -32,10 +32,16 @@ import { startUiBridge } from './ws-bridge.js'
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
|
||||
|
||||
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
|
||||
const uiBridge = startUiBridge(WS_UI_PORT)
|
||||
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions.
|
||||
// If the port is already in use (e.g. by the running Laputa app), continue
|
||||
// without the bridge — vault tools still work via stdio MCP.
|
||||
let uiBridge = null
|
||||
startUiBridge(WS_UI_PORT).then((bridge) => {
|
||||
uiBridge = bridge
|
||||
})
|
||||
|
||||
function broadcastUiAction(action, payload) {
|
||||
if (!uiBridge) return
|
||||
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
|
||||
for (const client of uiBridge.clients) {
|
||||
if (client.readyState === 1) client.send(msg)
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* Protocol (UI bridge):
|
||||
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote,
|
||||
@@ -80,15 +81,34 @@ async function handleMessage(data) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to start the UI bridge WebSocket server.
|
||||
* Returns a Promise that resolves to the WebSocketServer or null if the port
|
||||
* is unavailable (e.g. another Laputa instance owns it).
|
||||
*/
|
||||
export function startUiBridge(port = WS_UI_PORT) {
|
||||
uiBridge = new WebSocketServer({ port })
|
||||
return new Promise((resolve) => {
|
||||
const httpServer = createServer()
|
||||
|
||||
uiBridge.on('connection', () => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
httpServer.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`[ws-bridge] UI bridge port ${port} already in use, disabling bridge`)
|
||||
} else {
|
||||
console.error(`[ws-bridge] UI bridge error: ${err.message}`)
|
||||
}
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
httpServer.listen(port, () => {
|
||||
const wss = new WebSocketServer({ server: httpServer })
|
||||
wss.on('connection', () => {
|
||||
console.error(`[ws-bridge] UI client connected on port ${port}`)
|
||||
})
|
||||
uiBridge = wss
|
||||
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
|
||||
resolve(wss)
|
||||
})
|
||||
})
|
||||
|
||||
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
|
||||
return uiBridge
|
||||
}
|
||||
|
||||
export function startBridge(port = WS_PORT) {
|
||||
@@ -116,6 +136,5 @@ export function startBridge(port = WS_PORT) {
|
||||
// Run directly if invoked as main module
|
||||
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
|
||||
if (isMain) {
|
||||
startUiBridge()
|
||||
startBridge()
|
||||
startUiBridge().then(() => startBridge())
|
||||
}
|
||||
|
||||
@@ -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
486
src-tauri/src/indexing.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ const NOTE_TRASH: &str = "note-trash";
|
||||
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
|
||||
const VIEW_GO_BACK: &str = "view-go-back";
|
||||
const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
|
||||
|
||||
const CUSTOM_IDS: &[&str] = &[
|
||||
APP_SETTINGS,
|
||||
@@ -44,6 +45,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_ZOOM_RESET,
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
APP_CHECK_FOR_UPDATES,
|
||||
];
|
||||
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
@@ -56,10 +58,15 @@ fn build_app_menu(app: &App) -> MenuResult {
|
||||
.id(APP_SETTINGS)
|
||||
.accelerator("CmdOrCtrl+,")
|
||||
.build(app)?;
|
||||
let check_updates_item = MenuItemBuilder::new("Check for Updates...")
|
||||
.id(APP_CHECK_FOR_UPDATES)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Laputa")
|
||||
.about(None)
|
||||
.separator()
|
||||
.item(&check_updates_item)
|
||||
.separator()
|
||||
.item(&settings_item)
|
||||
.separator()
|
||||
.services()
|
||||
@@ -269,6 +276,7 @@ mod tests {
|
||||
"view-zoom-reset",
|
||||
"view-go-back",
|
||||
"view-go-forward",
|
||||
"app-check-for-updates",
|
||||
];
|
||||
for id in &expected {
|
||||
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
]
|
||||
|
||||
165
src/App.tsx
165
src/App.tsx
@@ -17,7 +17,6 @@ import { useMcpRegistration } from './hooks/useMcpRegistration'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { useEditorSave } from './hooks/useEditorSave'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
@@ -25,17 +24,21 @@ import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { useDialogs } from './hooks/useDialogs'
|
||||
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useGitHistory } from './hooks/useGitHistory'
|
||||
import { useUpdater } from './hooks/useUpdater'
|
||||
import { useUpdater, restartApp } from './hooks/useUpdater'
|
||||
import { useNavigationHistory } from './hooks/useNavigationHistory'
|
||||
import { useAutoSync } from './hooks/useAutoSync'
|
||||
import { useConflictResolver } from './hooks/useConflictResolver'
|
||||
import { useIndexing } from './hooks/useIndexing'
|
||||
import { useZoom } from './hooks/useZoom'
|
||||
import { useBuildNumber } from './hooks/useBuildNumber'
|
||||
import { useOnboarding } from './hooks/useOnboarding'
|
||||
import { useThemeManager } from './hooks/useThemeManager'
|
||||
import { useEditorSaveWithLinks } from './hooks/useEditorSaveWithLinks'
|
||||
import { useNavigationGestures } from './hooks/useNavigationGestures'
|
||||
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
||||
import { UpdateBanner } from './components/UpdateBanner'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from './mock-tauri'
|
||||
import { extractOutgoingLinks } from './utils/wikilinks'
|
||||
import type { SidebarSelection } from './types'
|
||||
import './App.css'
|
||||
|
||||
@@ -77,34 +80,6 @@ function useLayoutPanels() {
|
||||
}
|
||||
|
||||
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
||||
function useEditorSaveWithLinks(config: {
|
||||
updateContent: (path: string, content: string) => void
|
||||
updateEntry: (path: string, patch: Partial<import('./types').VaultEntry>) => void
|
||||
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave: () => void
|
||||
onNotePersisted?: (path: string) => void
|
||||
}) {
|
||||
const { updateContent, updateEntry } = config
|
||||
const saveContent = useCallback((path: string, content: string) => {
|
||||
updateContent(path, content)
|
||||
updateEntry(path, { outgoingLinks: extractOutgoingLinks(content) })
|
||||
}, [updateContent, updateEntry])
|
||||
const editor = useEditorSave({ updateVaultContent: saveContent, setTabs: config.setTabs, setToastMessage: config.setToastMessage, onAfterSave: config.onAfterSave, onNotePersisted: config.onNotePersisted })
|
||||
const { handleContentChange: rawOnChange } = editor
|
||||
const prevLinksKeyRef = useRef('')
|
||||
const handleContentChange = useCallback((path: string, content: string) => {
|
||||
rawOnChange(path, content)
|
||||
const links = extractOutgoingLinks(content)
|
||||
const key = links.join('\0')
|
||||
if (key !== prevLinksKeyRef.current) {
|
||||
prevLinksKeyRef.current = key
|
||||
updateEntry(path, { outgoingLinks: links })
|
||||
}
|
||||
}, [rawOnChange, updateEntry])
|
||||
return { ...editor, handleContentChange }
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const layout = useLayoutPanels()
|
||||
@@ -135,11 +110,40 @@ function App() {
|
||||
onVaultUpdated: vault.reloadVault,
|
||||
onConflict: (files) => {
|
||||
const names = files.map((f) => f.split('/').pop()).join(', ')
|
||||
setToastMessage(`Conflict in ${names} — review needed`)
|
||||
setToastMessage(`Conflict in ${names} — click to resolve`)
|
||||
},
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
|
||||
// Ref bridges for conflict resolution callbacks (notes declared below)
|
||||
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
|
||||
|
||||
const indexing = useIndexing(resolvedPath)
|
||||
|
||||
const conflictResolver = useConflictResolver({
|
||||
vaultPath: resolvedPath,
|
||||
onResolved: () => {
|
||||
dialogs.closeConflictResolver()
|
||||
autoSync.resumePull()
|
||||
vault.reloadVault()
|
||||
autoSync.triggerSync()
|
||||
},
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
onOpenFile: (relativePath) => openConflictFileRef.current(relativePath),
|
||||
})
|
||||
|
||||
const handleOpenConflictResolver = useCallback(() => {
|
||||
if (autoSync.conflictFiles.length === 0) return
|
||||
autoSync.pausePull()
|
||||
conflictResolver.initFiles(autoSync.conflictFiles)
|
||||
dialogs.openConflictResolver()
|
||||
}, [autoSync, conflictResolver, dialogs])
|
||||
|
||||
const handleCloseConflictResolver = useCallback(() => {
|
||||
autoSync.resumePull()
|
||||
dialogs.closeConflictResolver()
|
||||
}, [autoSync, dialogs])
|
||||
|
||||
// Ref bridges handleContentChange (created after notes) into useNoteActions.
|
||||
// Read at callback time, so it's always current when user presses Cmd+N.
|
||||
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
|
||||
@@ -185,53 +189,33 @@ function App() {
|
||||
}
|
||||
}, [navHistory, isEntryExists, vault.entries, notes])
|
||||
|
||||
// Mouse button 3/4 (back/forward) and macOS trackpad two-finger swipe
|
||||
useEffect(() => {
|
||||
const handleMouseBack = (e: MouseEvent) => {
|
||||
if (e.button === 3) { e.preventDefault(); handleGoBack() }
|
||||
if (e.button === 4) { e.preventDefault(); handleGoForward() }
|
||||
}
|
||||
window.addEventListener('mouseup', handleMouseBack)
|
||||
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
|
||||
|
||||
// Trackpad swipe: accumulate horizontal wheel delta and trigger on threshold
|
||||
let accumulatedDeltaX = 0
|
||||
let resetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const SWIPE_THRESHOLD = 120
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Only handle horizontal-dominant gestures (trackpad swipe)
|
||||
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
|
||||
if (e.ctrlKey || e.metaKey) return // ignore pinch-zoom
|
||||
|
||||
accumulatedDeltaX += e.deltaX
|
||||
|
||||
if (resetTimer) clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => { accumulatedDeltaX = 0 }, 300)
|
||||
|
||||
if (accumulatedDeltaX > SWIPE_THRESHOLD) {
|
||||
accumulatedDeltaX = 0
|
||||
handleGoForward()
|
||||
} else if (accumulatedDeltaX < -SWIPE_THRESHOLD) {
|
||||
accumulatedDeltaX = 0
|
||||
handleGoBack()
|
||||
}
|
||||
}
|
||||
window.addEventListener('wheel', handleWheel, { passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', handleMouseBack)
|
||||
window.removeEventListener('wheel', handleWheel)
|
||||
if (resetTimer) clearTimeout(resetTimer)
|
||||
}
|
||||
}, [handleGoBack, handleGoForward])
|
||||
const { triggerIncrementalIndex } = indexing
|
||||
const onAfterSave = useCallback(() => {
|
||||
vault.loadModifiedFiles()
|
||||
triggerIncrementalIndex()
|
||||
}, [vault, triggerIncrementalIndex])
|
||||
|
||||
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
||||
updateContent: vault.updateContent, updateEntry: vault.updateEntry,
|
||||
setTabs: notes.setTabs, setToastMessage, onAfterSave: vault.loadModifiedFiles,
|
||||
setTabs: notes.setTabs, setToastMessage, onAfterSave,
|
||||
onNotePersisted: vault.clearUnsaved,
|
||||
})
|
||||
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
||||
|
||||
// Wire conflict file opener now that notes is available
|
||||
useEffect(() => {
|
||||
openConflictFileRef.current = (relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = vault.entries.find(e => e.path === fullPath)
|
||||
if (entry) {
|
||||
notes.handleSelectNote(entry)
|
||||
dialogs.closeConflictResolver()
|
||||
}
|
||||
}
|
||||
}, [resolvedPath, vault.entries, notes, dialogs])
|
||||
|
||||
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
|
||||
const handleSave = useCallback(async () => {
|
||||
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
||||
@@ -296,6 +280,14 @@ function App() {
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
|
||||
const handleCheckForUpdates = useCallback(async () => {
|
||||
if (updateStatus.state === 'downloading') {
|
||||
setToastMessage('Update is downloading…')
|
||||
return
|
||||
}
|
||||
if (updateStatus.state === 'ready') {
|
||||
await restartApp()
|
||||
return
|
||||
}
|
||||
const result = await updateActions.checkForUpdates()
|
||||
if (result === 'up-to-date') {
|
||||
setToastMessage("You're on the latest version")
|
||||
@@ -303,7 +295,7 @@ function App() {
|
||||
setToastMessage('Could not check for updates')
|
||||
}
|
||||
// 'available' → UpdateBanner handles it automatically
|
||||
}, [updateActions, setToastMessage])
|
||||
}, [updateActions, updateStatus.state, setToastMessage])
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
@@ -321,7 +313,10 @@ function App() {
|
||||
onOpenSettings: dialogs.openSettings,
|
||||
onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote,
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
|
||||
onCommitPush: commitFlow.openCommitDialog,
|
||||
onResolveConflicts: handleOpenConflictResolver,
|
||||
conflictCount: autoSync.conflictFiles.length,
|
||||
onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
onToggleDiff: () => diffToggleRef.current(),
|
||||
onToggleRawEditor: () => rawToggleRef.current(),
|
||||
@@ -335,9 +330,13 @@ function App() {
|
||||
themes: themeManager.themes, activeThemeId: themeManager.activeThemeId,
|
||||
onSwitchTheme: themeManager.switchTheme,
|
||||
onCreateTheme: async () => {
|
||||
await themeManager.createTheme()
|
||||
await vault.reloadVault()
|
||||
const path = await themeManager.createTheme()
|
||||
const freshEntries = await vault.reloadVault()
|
||||
setSelection({ kind: 'sectionGroup', type: 'Theme' })
|
||||
if (path) {
|
||||
const entry = freshEntries.find(e => e.path === path)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}
|
||||
},
|
||||
onOpenTheme: (themeId: string) => {
|
||||
const entry = vault.entries.find(e => e.path === themeId)
|
||||
@@ -347,7 +346,6 @@ function App() {
|
||||
onCreateType: dialogs.openCreateType,
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
onCheckForUpdates: handleCheckForUpdates,
|
||||
isUpdating: updateStatus.state === 'downloading' || updateStatus.state === 'ready',
|
||||
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
|
||||
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
|
||||
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
|
||||
@@ -399,7 +397,7 @@ function App() {
|
||||
{noteListVisible && (
|
||||
<>
|
||||
<div className="app__note-list" style={{ width: layout.noteListWidth }}>
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
</>
|
||||
@@ -452,13 +450,24 @@ function App() {
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onRemoveVault={vaultSwitcher.removeVault} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} indexingProgress={indexing.progress} onRemoveVault={vaultSwitcher.removeVault} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
fileStates={conflictResolver.fileStates}
|
||||
allResolved={conflictResolver.allResolved}
|
||||
committing={conflictResolver.committing}
|
||||
error={conflictResolver.error}
|
||||
onResolveFile={conflictResolver.resolveFile}
|
||||
onOpenInEditor={conflictResolver.openInEditor}
|
||||
onCommit={conflictResolver.commitResolution}
|
||||
onClose={handleCloseConflictResolver}
|
||||
/>
|
||||
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} themeManager={themeManager} />
|
||||
<GitHubVaultModal
|
||||
open={dialogs.showGitHubVault}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -133,4 +133,33 @@ describe('AiPanel', () => {
|
||||
const input = screen.getByTestId('agent-input') as HTMLInputElement
|
||||
expect(input.placeholder).toBe('Ask the AI agent...')
|
||||
})
|
||||
|
||||
it('auto-focuses input on mount', async () => {
|
||||
vi.useFakeTimers()
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
const input = screen.getByTestId('agent-input')
|
||||
expect(document.activeElement).toBe(input)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed while panel has focus', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
await act(() => { vi.advanceTimersByTime(1) })
|
||||
// Input is focused inside the panel, so Escape should trigger onClose
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('calls onClose when Escape is pressed on panel element', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<AiPanel onClose={onClose} vaultPath="/tmp/vault" />)
|
||||
const panel = screen.getByTestId('ai-panel')
|
||||
panel.focus()
|
||||
fireEvent.keyDown(panel, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Robot, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
||||
import { AiMessage } from './AiMessage'
|
||||
import { useAiAgent, type AiAgentMessage } from '../hooks/useAiAgent'
|
||||
@@ -103,10 +103,10 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
)
|
||||
}
|
||||
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext }: {
|
||||
function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContext, inputRef }: {
|
||||
input: string; onInputChange: (v: string) => void
|
||||
onSend: () => void; onKeyDown: (e: React.KeyboardEvent) => void
|
||||
isActive: boolean; hasContext: boolean
|
||||
isActive: boolean; hasContext: boolean; inputRef: React.RefObject<HTMLInputElement | null>
|
||||
}) {
|
||||
const sendDisabled = isActive || !input.trim()
|
||||
return (
|
||||
@@ -116,6 +116,7 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContex
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -150,6 +151,8 @@ function InputBar({ input, onInputChange, onSend, onKeyDown, isActive, hasContex
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries, allContent }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const panelRef = useRef<HTMLElement>(null)
|
||||
|
||||
const linkedEntries = useMemo(() => {
|
||||
if (!activeEntry || !entries) return []
|
||||
@@ -163,9 +166,33 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt)
|
||||
const hasContext = !!activeEntry
|
||||
|
||||
const isActive = agent.status === 'thinking' || agent.status === 'tool-executing'
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
panelRef.current?.focus()
|
||||
} else {
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
const handleEscape = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && panelRef.current?.contains(document.activeElement)) {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isActive) return
|
||||
agent.sendMessage(input)
|
||||
@@ -181,7 +208,10 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
|
||||
style={{ outline: 'none' }}
|
||||
data-testid="ai-panel"
|
||||
>
|
||||
<PanelHeader onClose={onClose} onClear={agent.clearConversation} />
|
||||
@@ -201,6 +231,7 @@ export function AiPanel({ onClose, onOpenNote, vaultPath, activeEntry, entries,
|
||||
onKeyDown={handleKeyDown}
|
||||
isActive={isActive}
|
||||
hasContext={hasContext}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
|
||||
246
src/components/ConflictResolverModal.tsx
Normal file
246
src/components/ConflictResolverModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -75,7 +75,7 @@ const mockEntry: VaultEntry = {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Package, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, X } from 'lucide-react'
|
||||
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2, GitCommitHorizontal, Search, X } from 'lucide-react'
|
||||
import type { LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { IndexingProgress } from '../hooks/useIndexing'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
export interface VaultOption {
|
||||
@@ -25,9 +26,11 @@ interface StatusBarProps {
|
||||
conflictCount?: number
|
||||
lastCommitInfo?: LastCommitInfo | null
|
||||
onTriggerSync?: () => void
|
||||
onOpenConflictResolver?: () => void
|
||||
zoomLevel?: number
|
||||
onZoomReset?: () => void
|
||||
buildNumber?: string
|
||||
indexingProgress?: IndexingProgress
|
||||
onRemoveVault?: (path: string) => void
|
||||
}
|
||||
|
||||
@@ -190,15 +193,17 @@ function CommitBadge({ info }: { info: LastCommitInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SyncBadge({ status, lastSyncTime, onTriggerSync }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void }) {
|
||||
function SyncBadge({ status, lastSyncTime, onTriggerSync, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void; onOpenConflictResolver?: () => void }) {
|
||||
const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw
|
||||
const isSyncing = status === 'syncing'
|
||||
const isConflict = status === 'conflict'
|
||||
const handleClick = isConflict ? onOpenConflictResolver : onTriggerSync
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
onClick={onTriggerSync}
|
||||
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={isSyncing ? 'Syncing…' : 'Click to sync now'}
|
||||
onClick={handleClick}
|
||||
style={{ ...ICON_STYLE, cursor: handleClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
|
||||
title={isConflict ? 'Click to resolve conflicts' : isSyncing ? 'Syncing…' : 'Click to sync now'}
|
||||
data-testid="status-sync"
|
||||
>
|
||||
<SyncIcon size={13} style={{ color: syncIconColor(status) }} className={isSyncing ? 'animate-spin' : ''} />{formatSyncLabel(status, lastSyncTime)}
|
||||
@@ -206,18 +211,58 @@ function SyncBadge({ status, lastSyncTime, onTriggerSync }: { status: SyncStatus
|
||||
)
|
||||
}
|
||||
|
||||
function ConflictBadge({ count }: { count: number }) {
|
||||
function ConflictBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
|
||||
<span
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)', cursor: onClick ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title="Resolve merge conflicts"
|
||||
onMouseEnter={onClick ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onClick ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
data-testid="status-conflict-count"
|
||||
>
|
||||
<AlertTriangle size={13} />{count} conflict{count > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const INDEXING_LABELS: Record<string, string> = {
|
||||
installing: 'Installing search…',
|
||||
scanning: 'Indexing…',
|
||||
embedding: 'Embedding…',
|
||||
complete: 'Index ready',
|
||||
error: 'Index error',
|
||||
}
|
||||
|
||||
function IndexingBadge({ progress }: { progress: IndexingProgress }) {
|
||||
if (progress.phase === 'idle') return null
|
||||
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
|
||||
const isActive = !progress.done
|
||||
const showCount = progress.total > 0 && isActive
|
||||
const displayText = showCount
|
||||
? `${label} ${progress.current.toLocaleString()}/${progress.total.toLocaleString()}`
|
||||
: label
|
||||
const color = progress.phase === 'error' ? 'var(--accent-orange)' : 'var(--accent-blue, #3b82f6)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={{ ...ICON_STYLE, color }} data-testid="status-indexing">
|
||||
{isActive
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <Search size={13} />
|
||||
}
|
||||
{displayText}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBadge({ count, onClick }: { count: number; onClick?: () => void }) {
|
||||
if (count <= 0) return null
|
||||
return (
|
||||
@@ -236,7 +281,7 @@ function PendingBadge({ count, onClick }: { count: number; onClick?: () => void
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, zoomLevel = 100, onZoomReset, buildNumber, onRemoveVault }: StatusBarProps) {
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, indexingProgress, onRemoveVault }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
@@ -250,13 +295,13 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span style={ICON_STYLE} data-testid="status-build-number"><Package size={13} />{buildNumber ?? 'b?'}</span>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} />
|
||||
<SyncBadge status={syncStatus} lastSyncTime={lastSyncTime} onTriggerSync={onTriggerSync} onOpenConflictResolver={onOpenConflictResolver} />
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} />
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
{indexingProgress && <IndexingBadge progress={indexingProgress} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={ICON_STYLE}><Sparkles size={13} style={{ color: 'var(--accent-purple)' }} />Claude Sonnet 4</span>
|
||||
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>
|
||||
{zoomLevel !== 100 && (
|
||||
<span
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ interface AppCommandsConfig {
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onResolveConflicts?: () => void
|
||||
conflictCount?: number
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleDiff?: () => void
|
||||
@@ -58,7 +60,6 @@ interface AppCommandsConfig {
|
||||
onCreateType?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
isUpdating?: boolean
|
||||
onRemoveActiveVault?: () => void
|
||||
onRestoreGettingStarted?: () => void
|
||||
isGettingStartedHidden?: boolean
|
||||
@@ -120,6 +121,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onSearch: config.onSearch,
|
||||
onGoBack: config.onGoBack,
|
||||
onGoForward: config.onGoForward,
|
||||
onCheckForUpdates: config.onCheckForUpdates,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
activeTabPath: config.activeTabPath,
|
||||
@@ -139,6 +141,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onArchiveNote: config.onArchiveNote,
|
||||
onUnarchiveNote: config.onUnarchiveNote,
|
||||
onCommitPush: config.onCommitPush,
|
||||
onResolveConflicts: config.onResolveConflicts,
|
||||
conflictCount: config.conflictCount,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleDiff: config.onToggleDiff,
|
||||
@@ -164,7 +168,6 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onCreateType: config.onCreateType,
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onCheckForUpdates: config.onCheckForUpdates,
|
||||
isUpdating: config.isUpdating,
|
||||
onRemoveActiveVault: config.onRemoveActiveVault,
|
||||
onRestoreGettingStarted: config.onRestoreGettingStarted,
|
||||
isGettingStartedHidden: config.isGettingStartedHidden,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
@@ -369,20 +369,11 @@ describe('useCommandRegistry', () => {
|
||||
expect(cmd!.keywords).toContain('version')
|
||||
})
|
||||
|
||||
it('is enabled when not updating', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ isUpdating: false })),
|
||||
)
|
||||
it('is always enabled', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is disabled when updating', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ isUpdating: true })),
|
||||
)
|
||||
expect(result.current.find(c => c.id === 'check-updates')!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls onCheckForUpdates when executed', () => {
|
||||
const onCheckForUpdates = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -18,6 +18,7 @@ interface CommandRegistryConfig {
|
||||
activeTabPath: string | null
|
||||
entries: VaultEntry[]
|
||||
modifiedCount: number
|
||||
conflictCount?: number
|
||||
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
@@ -31,6 +32,7 @@ interface CommandRegistryConfig {
|
||||
onArchiveNote: (path: string) => void
|
||||
onUnarchiveNote: (path: string) => void
|
||||
onCommitPush: () => void
|
||||
onResolveConflicts?: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleDiff?: () => void
|
||||
@@ -38,7 +40,6 @@ interface CommandRegistryConfig {
|
||||
onToggleAIChat?: () => void
|
||||
activeNoteModified: boolean
|
||||
onCheckForUpdates?: () => void
|
||||
isUpdating?: boolean
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
@@ -180,16 +181,16 @@ export function buildThemeCommands(
|
||||
|
||||
export function useCommandRegistry(config: CommandRegistryConfig): CommandAction[] {
|
||||
const {
|
||||
activeTabPath, entries, modifiedCount,
|
||||
activeTabPath, entries, modifiedCount, conflictCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
activeNoteModified,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
|
||||
onCheckForUpdates, isUpdating,
|
||||
onCheckForUpdates,
|
||||
onCreateType,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
} = config
|
||||
@@ -236,6 +237,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
|
||||
// Git
|
||||
{ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush },
|
||||
{ id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: (conflictCount ?? 0) > 0, execute: () => onResolveConflicts?.() },
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
|
||||
// View
|
||||
@@ -249,7 +251,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
|
||||
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
|
||||
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: !isUpdating, execute: () => onCheckForUpdates?.() },
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
|
||||
|
||||
// Type-aware: "New [Type]" and "List [Type]"
|
||||
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
|
||||
@@ -257,15 +259,15 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
|
||||
return cmds
|
||||
}, [
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified,
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, conflictCount, activeNoteModified,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
onCheckForUpdates, isUpdating,
|
||||
onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onCheckForUpdates,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
])
|
||||
}
|
||||
|
||||
173
src/hooks/useConflictResolver.test.ts
Normal file
173
src/hooks/useConflictResolver.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
96
src/hooks/useConflictResolver.ts
Normal file
96
src/hooks/useConflictResolver.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
32
src/hooks/useEditorSaveWithLinks.ts
Normal file
32
src/hooks/useEditorSaveWithLinks.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
159
src/hooks/useIndexing.test.ts
Normal file
159
src/hooks/useIndexing.test.ts
Normal 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
116
src/hooks/useIndexing.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onSearch: vi.fn(),
|
||||
onGoBack: vi.fn(),
|
||||
onGoForward: vi.fn(),
|
||||
onCheckForUpdates: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
|
||||
activeTabPath: '/vault/test.md',
|
||||
@@ -161,6 +162,12 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onGoForward).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('app-check-for-updates triggers check for updates', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('app-check-for-updates', h)
|
||||
expect(h.onCheckForUpdates).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unknown event ID does nothing', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('unknown-event', h)
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface MenuEventHandlers {
|
||||
onSearch: () => void
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
onCheckForUpdates?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
activeTabPath: string | null
|
||||
@@ -58,6 +59,7 @@ function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
function dispatchOptionalEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
if (id === 'view-go-back') { h.onGoBack?.(); return true }
|
||||
if (id === 'view-go-forward') { h.onGoForward?.(); return true }
|
||||
if (id === 'app-check-for-updates') { h.onCheckForUpdates?.(); return true }
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
52
src/hooks/useNavigationGestures.ts
Normal file
52
src/hooks/useNavigationGestures.ts
Normal 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])
|
||||
}
|
||||
@@ -59,7 +59,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
color: null,
|
||||
order: null,
|
||||
outgoingLinks: [],
|
||||
template: null,
|
||||
template: null, sort: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
||||
@@ -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] ?? {}
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -172,8 +172,8 @@ export function useVaultLoader(vaultPath: string) {
|
||||
|
||||
const reloadVault = useCallback(
|
||||
() => loadVaultData(vaultPath)
|
||||
.then((data) => { setEntries(data.entries); setAllContent((prev) => ({ ...prev, ...data.allContent })); loadModifiedFiles() })
|
||||
.catch((err) => console.warn('Vault reload failed:', err)),
|
||||
.then((data) => { setEntries(data.entries); setAllContent((prev) => ({ ...prev, ...data.allContent })); loadModifiedFiles(); return data.entries })
|
||||
.catch((err) => { console.warn('Vault reload failed:', err); return [] as VaultEntry[] }),
|
||||
[vaultPath, loadModifiedFiles],
|
||||
)
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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$/, '')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null,
|
||||
template: null, sort: null,
|
||||
outgoingLinks: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user