Compare commits

..

10 Commits

Author SHA1 Message Date
Test
20b4ba7a3b fix: clippy errors — reduce visibility of internal functions, fix PI approx
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:19:45 +01:00
Test
de00ab6794 feat: reindex vault command, indexing status bar, sync-triggered reindex
- Add "Reindex Vault" command to command palette and menu bar
- Show "Indexed Xm ago" in status bar when idle, clickable to reindex
- After git pull with updates, auto-trigger incremental reindex
- Add lastIndexedTime state to useIndexing, populated from backend metadata
- Add triggerFullReindex to useIndexing (retryIndexing is now an alias)
- Add onSyncUpdated callback to useAutoSync
- Extract formatIndexedElapsed to utils/indexingHelpers.ts
- Tests: 20 new unit tests across 5 files, 2 Playwright smoke tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:14:12 +01:00
Test
5d8f514bea feat: add last_indexed_commit persistence to indexing backend
Store last_indexed_commit and last_indexed_at in .laputa-index.json
after every successful full or incremental index. Include these in
IndexStatus so the frontend can display staleness. Add
needs_reindex_after_sync() helper that compares HEAD vs stored commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:00:58 +01:00
Test
1fd3ea02ae fix: rustfmt import formatting in commands.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:38:08 +01:00
Test
8da1484ebf test: Playwright smoke test for push error UX
Expose mockHandlers on window for Playwright overrides. Test that
rejected push shows "Pull first" message, auth error shows
"authentication error", and success shows "Committed and pushed".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:35:35 +01:00
Test
90ebc2e939 feat: surface actionable push error messages in frontend
Update commitWithPush to parse GitPushResult from backend and show
specific messages (rejected, auth, network) instead of generic
"push failed". Tests verify rejected + network error scenarios.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:31:46 +01:00
Test
c14927df8f feat: add GitPushResult with error classification for push failures
Replaces raw string return from git_push with a structured GitPushResult
that classifies errors as rejected/auth_error/network_error/error, each
with an actionable user-facing message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:29:54 +01:00
Test
a6d60695a2 style: rustfmt formatting fix in cache test
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:22:33 +01:00
Test
7ddc0c14bf fix: add visible key to frontmatterToEntryPatch maps
The ENTRY_DELETE_MAP and update map in frontmatterToEntryPatch were
missing the 'visible' key. When handleDeleteProperty or
handleUpdateFrontmatter was called for 'visible', the in-memory
VaultEntry was not updated, causing the sidebar to stay stale even
after the file on disk was correctly modified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:20:51 +01:00
Test
2a00b8aac7 fix: include full conversation history in AI chat requests
- Add trimHistory() to keep most recent messages within 100k token budget
- Add formatMessageWithHistory() to prepend conversation context to each message
- Wire up in useAIChat.ts: sendMessage now includes full history
- Keep --resume as belt-and-suspenders for same-session continuity
- 14 new tests in ai-chat.test.ts and useAIChat.test.ts
2026-03-06 23:47:59 +01:00
34 changed files with 1171 additions and 46 deletions

View File

@@ -5,7 +5,9 @@ use crate::claude_cli::{
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
};
use crate::frontmatter::FrontmatterValue;
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
use crate::git::{
GitCommit, GitPullResult, GitPushResult, LastCommitInfo, ModifiedFile, PulseCommit,
};
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
use crate::indexing::{IndexStatus, IndexingProgress};
use crate::search::SearchResponse;
@@ -276,7 +278,7 @@ pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, Stri
}
#[tauri::command]
pub fn git_push(vault_path: String) -> Result<String, String> {
pub fn git_push(vault_path: String) -> Result<GitPushResult, String> {
let vault_path = expand_tilde(&vault_path);
git::git_push(&vault_path)
}

View File

@@ -193,8 +193,8 @@ mod tests {
#[test]
fn test_to_yaml_value_number_float() {
let v = FrontmatterValue::Number(3.14);
assert_eq!(v.to_yaml_value(), "3.14");
let v = FrontmatterValue::Number(3.125);
assert_eq!(v.to_yaml_value(), "3.125");
}
#[test]

View File

@@ -15,7 +15,7 @@ pub use conflict::{
};
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
pub use remote::{git_pull, git_push, has_remote, GitPullResult};
pub use remote::{git_pull, git_push, has_remote, GitPullResult, GitPushResult};
pub use status::{get_modified_files, ModifiedFile};
use serde::Serialize;

View File

@@ -110,8 +110,74 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
.collect()
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct GitPushResult {
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
pub message: String,
}
/// Classify a git push stderr message into a user-friendly status and message.
pub fn classify_push_error(stderr: &str) -> GitPushResult {
let lower = stderr.to_lowercase();
if lower.contains("non-fast-forward")
|| lower.contains("[rejected]")
|| lower.contains("fetch first")
|| lower.contains("failed to push some refs")
&& (lower.contains("updates were rejected") || lower.contains("non-fast-forward"))
{
return GitPushResult {
status: "rejected".to_string(),
message: "Push rejected: remote has new commits. Pull first, then push.".to_string(),
};
}
if lower.contains("authentication failed")
|| lower.contains("could not read username")
|| lower.contains("permission denied")
|| lower.contains("403")
|| lower.contains("invalid credentials")
{
return GitPushResult {
status: "auth_error".to_string(),
message: "Push failed: authentication error. Check your credentials.".to_string(),
};
}
if lower.contains("could not resolve host")
|| lower.contains("unable to access")
|| lower.contains("connection refused")
|| lower.contains("network is unreachable")
|| lower.contains("timed out")
{
return GitPushResult {
status: "network_error".to_string(),
message: "Push failed: network error. Check your connection and try again.".to_string(),
};
}
// Fallback: extract the hint line if present, otherwise use the full stderr
let hint_line = stderr
.lines()
.find(|l| l.trim_start().starts_with("hint:"))
.map(|l| l.trim_start().strip_prefix("hint:").unwrap_or(l).trim())
.unwrap_or("")
.to_string();
let detail = if hint_line.is_empty() {
stderr.trim().to_string()
} else {
hint_line
};
GitPushResult {
status: "error".to_string(),
message: format!("Push failed: {detail}"),
}
}
/// Push to remote.
pub fn git_push(vault_path: &str) -> Result<String, String> {
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
let vault = Path::new(vault_path);
let output = Command::new("git")
@@ -122,13 +188,13 @@ pub fn git_push(vault_path: &str) -> Result<String, String> {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git push failed: {}", stderr));
return Ok(classify_push_error(&stderr));
}
// git push often writes to stderr even on success
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(format!("{}{}", stdout, stderr))
Ok(GitPushResult {
status: "ok".to_string(),
message: "Pushed to remote".to_string(),
})
}
#[cfg(test)]
@@ -227,6 +293,104 @@ mod tests {
assert!(files.is_empty());
}
#[test]
fn test_classify_push_error_non_fast_forward() {
let stderr = r#"To github.com:user/repo.git
! [rejected] main -> main (non-fast-forward)
error: failed to push some refs to 'github.com:user/repo.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally."#;
let result = classify_push_error(stderr);
assert_eq!(result.status, "rejected");
assert!(result.message.contains("Pull first"));
}
#[test]
fn test_classify_push_error_fetch_first() {
let stderr = "error: failed to push some refs\nhint: Updates were rejected because the tip of your current branch is behind\nhint: its remote counterpart. Integrate the remote changes (e.g.\nhint: 'git pull ...') before pushing again.\nhint: See the 'Note about fast-forwards' in 'git push --help' for details.\n ! [rejected] main -> main (fetch first)\n";
let result = classify_push_error(stderr);
assert_eq!(result.status, "rejected");
}
#[test]
fn test_classify_push_error_auth_failure() {
let stderr = "remote: Permission denied to user/repo.git\nfatal: unable to access 'https://github.com/user/repo.git/': The requested URL returned error: 403";
let result = classify_push_error(stderr);
assert_eq!(result.status, "auth_error");
assert!(result.message.contains("authentication"));
}
#[test]
fn test_classify_push_error_network() {
let stderr = "fatal: unable to access 'https://github.com/user/repo.git/': Could not resolve host: github.com";
let result = classify_push_error(stderr);
assert_eq!(result.status, "network_error");
assert!(result.message.contains("network"));
}
#[test]
fn test_classify_push_error_unknown() {
let stderr = "error: something unexpected happened\nhint: Try again later";
let result = classify_push_error(stderr);
assert_eq!(result.status, "error");
assert!(result.message.contains("Try again later"));
}
#[test]
fn test_classify_push_error_unknown_no_hint() {
let stderr = "error: something totally weird";
let result = classify_push_error(stderr);
assert_eq!(result.status, "error");
assert!(result.message.contains("something totally weird"));
}
#[test]
fn test_git_push_result_serialization() {
let result = GitPushResult {
status: "rejected".to_string(),
message: "Push rejected".to_string(),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"rejected\""));
let parsed: GitPushResult = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.status, "rejected");
}
#[test]
fn test_git_push_success_returns_ok() {
let (_bare, clone_a, _clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
git_commit(vp_a, "initial").unwrap();
let result = git_push(vp_a).unwrap();
assert_eq!(result.status, "ok");
}
#[test]
fn test_git_push_rejected_returns_rejected() {
let (_bare, clone_a, clone_b) = setup_remote_pair();
let vp_a = clone_a.path().to_str().unwrap();
let vp_b = clone_b.path().to_str().unwrap();
// Both clones commit and push — second push should be rejected
fs::write(clone_a.path().join("note.md"), "# A\n").unwrap();
git_commit(vp_a, "from A").unwrap();
git_push(vp_a).unwrap();
git_pull(vp_b).unwrap();
fs::write(clone_b.path().join("note.md"), "# B\n").unwrap();
git_commit(vp_b, "from B").unwrap();
git_push(vp_b).unwrap();
// Now A has a new commit but hasn't pulled B's changes
fs::write(clone_a.path().join("other.md"), "# Other\n").unwrap();
git_commit(vp_a, "from A again").unwrap();
let result = git_push(vp_a).unwrap();
assert_eq!(result.status, "rejected");
assert!(result.message.contains("Pull first"));
}
#[test]
fn test_git_pull_result_serialization() {
let result = GitPullResult {

View File

@@ -1,4 +1,4 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
@@ -223,10 +223,72 @@ pub struct IndexStatus {
pub indexed_count: usize,
pub embedded_count: usize,
pub pending_embed: usize,
pub last_indexed_commit: Option<String>,
pub last_indexed_at: Option<u64>,
}
// --- Index metadata persistence ---
#[derive(Debug, Serialize, Deserialize, Default)]
struct IndexMetadata {
#[serde(default)]
last_indexed_commit: Option<String>,
#[serde(default)]
last_indexed_at: Option<u64>,
}
fn index_metadata_path(vault_path: &str) -> PathBuf {
Path::new(vault_path).join(".laputa-index.json")
}
fn load_index_metadata(vault_path: &str) -> IndexMetadata {
let path = index_metadata_path(vault_path);
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_index_metadata(vault_path: &str, meta: &IndexMetadata) -> Result<(), String> {
let path = index_metadata_path(vault_path);
let json =
serde_json::to_string_pretty(meta).map_err(|e| format!("Failed to serialize: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Failed to write index metadata: {e}"))
}
/// Get the current HEAD commit hash for a vault.
fn get_head_commit(vault_path: &str) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(vault_path)
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
/// Record the current HEAD as the last indexed commit.
fn stamp_index_commit(vault_path: &str) {
if let Some(commit) = get_head_commit(vault_path) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let meta = IndexMetadata {
last_indexed_commit: Some(commit),
last_indexed_at: Some(now),
};
let _ = save_index_metadata(vault_path, &meta);
}
}
/// Check whether the vault has a qmd index and its status.
pub fn check_index_status(vault_path: &str) -> IndexStatus {
let meta = load_index_metadata(vault_path);
let qmd = match find_qmd_binary() {
Some(b) => b,
None => {
@@ -237,6 +299,8 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
last_indexed_commit: meta.last_indexed_commit,
last_indexed_at: meta.last_indexed_at,
}
}
};
@@ -244,7 +308,7 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
let vault_name = vault_dir_name(vault_path);
let output = qmd.command().args(["status"]).output();
match output {
let mut status = match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
parse_status_for_vault(&stdout, &vault_name)
@@ -256,8 +320,14 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
indexed_count: 0,
embedded_count: 0,
pending_embed: 0,
last_indexed_commit: None,
last_indexed_at: None,
},
}
};
status.last_indexed_commit = meta.last_indexed_commit;
status.last_indexed_at = meta.last_indexed_at;
status
}
fn vault_dir_name(vault_path: &str) -> String {
@@ -323,6 +393,8 @@ fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus
indexed_count,
embedded_count,
pending_embed,
last_indexed_commit: None,
last_indexed_at: None,
}
}
@@ -451,6 +523,7 @@ where
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}");
stamp_index_commit(vault_path);
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
@@ -461,6 +534,8 @@ where
return Ok(());
}
stamp_index_commit(vault_path);
on_progress(IndexingProgress {
phase: "complete".to_string(),
current: total,
@@ -513,9 +588,23 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
return Err(format!("qmd update failed: {stderr}"));
}
stamp_index_commit(vault_path);
Ok(())
}
/// Check if HEAD has advanced past the last indexed commit.
#[cfg(test)]
fn needs_reindex_after_sync(vault_path: &str) -> bool {
let meta = load_index_metadata(vault_path);
let head = get_head_commit(vault_path);
match (meta.last_indexed_commit, head) {
(Some(last), Some(current)) => last != current,
(None, Some(_)) => true,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -698,4 +787,126 @@ Collections
// It verifies the function doesn't panic.
let _ = find_bun();
}
#[test]
fn index_metadata_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Default when no file exists
let meta = load_index_metadata(vault);
assert!(meta.last_indexed_commit.is_none());
assert!(meta.last_indexed_at.is_none());
// Write and read back
let meta = IndexMetadata {
last_indexed_commit: Some("abc123def456".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
let loaded = load_index_metadata(vault);
assert_eq!(loaded.last_indexed_commit.as_deref(), Some("abc123def456"));
assert_eq!(loaded.last_indexed_at, Some(1709000000));
}
#[test]
fn index_metadata_survives_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Write garbage
std::fs::write(dir.path().join(".laputa-index.json"), "not json").unwrap();
let meta = load_index_metadata(vault);
assert!(meta.last_indexed_commit.is_none());
}
#[test]
fn needs_reindex_after_sync_no_metadata() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
// Init a git repo so get_head_commit works
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
// No metadata → needs reindex
assert!(needs_reindex_after_sync(vault));
}
#[test]
fn needs_reindex_after_sync_same_commit() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
let head = get_head_commit(vault).unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some(head),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
assert!(!needs_reindex_after_sync(vault));
}
#[test]
fn needs_reindex_after_sync_different_commit() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.output()
.unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some("old_commit_hash".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
assert!(needs_reindex_after_sync(vault));
}
#[test]
fn check_index_status_includes_metadata() {
let dir = tempfile::tempdir().unwrap();
let vault = dir.path().to_str().unwrap();
let meta = IndexMetadata {
last_indexed_commit: Some("abc123".to_string()),
last_indexed_at: Some(1709000000),
};
save_index_metadata(vault, &meta).unwrap();
let status = check_index_status(vault);
assert_eq!(status.last_indexed_commit.as_deref(), Some("abc123"));
assert_eq!(status.last_indexed_at, Some(1709000000));
}
}

View File

@@ -48,6 +48,7 @@ const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
const VAULT_REINDEX: &str = "vault-reindex";
const CUSTOM_IDS: &[&str] = &[
APP_SETTINGS,
@@ -88,6 +89,7 @@ const CUSTOM_IDS: &[&str] = &[
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
];
/// IDs of menu items that should be disabled when no note tab is active.
@@ -332,6 +334,9 @@ fn build_vault_menu(app: &App) -> MenuResult {
let install_mcp = MenuItemBuilder::new("Install MCP Server")
.id(VAULT_INSTALL_MCP)
.build(app)?;
let reindex = MenuItemBuilder::new("Reindex Vault")
.id(VAULT_REINDEX)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Vault")
.item(&open_vault)
@@ -345,6 +350,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
.item(&resolve_conflicts)
.item(&view_changes)
.separator()
.item(&reindex)
.item(&install_mcp)
.build()?)
}
@@ -462,6 +468,7 @@ mod tests {
VAULT_RESOLVE_CONFLICTS,
VAULT_VIEW_CHANGES,
VAULT_INSTALL_MCP,
VAULT_REINDEX,
];
for id in &expected {
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");

View File

@@ -646,4 +646,63 @@ mod tests {
assert!(titles.contains(&"Default Theme"));
assert!(titles.contains(&"Dark Theme"));
}
#[test]
fn test_update_same_commit_visible_removed_from_type_note() {
let dir = TempDir::new().unwrap();
let vault = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(vault)
.output()
.unwrap();
// Commit a type note with visible: false
create_test_file(
vault,
"type/topic.md",
"---\ntype: Type\nvisible: false\n---\n# Topic\n",
);
std::process::Command::new("git")
.args(["add", "."])
.current_dir(vault)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(vault)
.output()
.unwrap();
// Prime the cache
let entries = scan_vault_cached(vault).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].visible,
Some(false),
"visible must be false initially"
);
// User removes visible field (uncommitted edit)
create_test_file(vault, "type/topic.md", "---\ntype: Type\n---\n# Topic\n");
// Reload — must reflect the removal (visible defaults to None)
let entries2 = scan_vault_cached(vault).unwrap();
assert_eq!(entries2.len(), 1);
assert_eq!(
entries2[0].visible, None,
"visible must be None after removing the field"
);
}
}

View File

@@ -48,10 +48,12 @@ import { filterEntries } from './utils/noteListHelpers'
import { openLocalFile } from './utils/url'
import './App.css'
// Type declaration for mock content storage
// Type declarations for mock content storage and test overrides
declare global {
interface Window {
__mockContent?: Record<string, string>
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides
__mockHandlers?: Record<string, (args: any) => any>
}
}
@@ -111,10 +113,13 @@ function App() {
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const indexing = useIndexing(resolvedPath)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
intervalMinutes: settings.auto_pull_interval_minutes,
onVaultUpdated: vault.reloadVault,
onSyncUpdated: indexing.triggerIncrementalIndex,
onConflict: (files) => {
const names = files.map((f) => f.split('/').pop()).join(', ')
setToastMessage(`Conflict in ${names} — click to resolve`)
@@ -125,8 +130,6 @@ function App() {
// 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: () => {
@@ -464,6 +467,7 @@ function App() {
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
onReindexVault: indexing.triggerFullReindex,
})
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
@@ -585,7 +589,7 @@ 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} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRetryIndexing={indexing.retryIndexing} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<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} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<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} />

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { StatusBar } from './StatusBar'
import type { VaultOption } from './StatusBar'
import { formatIndexedElapsed } from '../utils/indexingHelpers'
vi.mock('../utils/url', async () => {
const actual = await vi.importActual('../utils/url')
@@ -394,4 +395,71 @@ describe('StatusBar', () => {
fireEvent.click(screen.getByTestId('status-mcp'))
expect(onInstallMcp).not.toHaveBeenCalled()
})
it('shows "Indexed just now" when lastIndexedTime is recent and 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 }}
lastIndexedTime={Date.now() - 5000}
/>
)
expect(screen.getByText(/Indexed just now/)).toBeInTheDocument()
expect(screen.getByTestId('status-indexed-time')).toBeInTheDocument()
})
it('calls onReindexVault when clicking the indexed time badge', () => {
const onReindexVault = vi.fn()
render(
<StatusBar
noteCount={100}
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
lastIndexedTime={Date.now() - 5000}
onReindexVault={onReindexVault}
/>
)
fireEvent.click(screen.getByTestId('status-indexed-time'))
expect(onReindexVault).toHaveBeenCalledOnce()
})
it('hides indexed time badge when no lastIndexedTime', () => {
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-indexed-time')).not.toBeInTheDocument()
})
})
describe('formatIndexedElapsed', () => {
it('returns empty string for null', () => {
expect(formatIndexedElapsed(null)).toBe('')
})
it('returns "Indexed just now" for < 60s', () => {
expect(formatIndexedElapsed(Date.now() - 30_000)).toBe('Indexed just now')
})
it('returns minutes for < 60min', () => {
expect(formatIndexedElapsed(Date.now() - 5 * 60_000)).toBe('Indexed 5m ago')
})
it('returns hours for < 24h', () => {
expect(formatIndexedElapsed(Date.now() - 3 * 3600_000)).toBe('Indexed 3h ago')
})
it('returns days for >= 24h', () => {
expect(formatIndexedElapsed(Date.now() - 48 * 3600_000)).toBe('Indexed 2d ago')
})
})

View File

@@ -4,6 +4,7 @@ import type { LastCommitInfo, SyncStatus } from '../types'
import type { IndexingProgress } from '../hooks/useIndexing'
import type { McpStatus } from '../hooks/useMcpStatus'
import { openExternalUrl } from '../utils/url'
import { formatIndexedElapsed } from '../utils/indexingHelpers'
export interface VaultOption {
label: string
@@ -33,7 +34,9 @@ interface StatusBarProps {
buildNumber?: string
onCheckForUpdates?: () => void
indexingProgress?: IndexingProgress
lastIndexedTime?: number | null
onRetryIndexing?: () => void
onReindexVault?: () => void
onRemoveVault?: (path: string) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
@@ -245,8 +248,32 @@ const INDEXING_LABELS: Record<string, string> = {
unavailable: 'Search unavailable',
}
function IndexingBadge({ progress, onRetry }: { progress: IndexingProgress; onRetry?: () => void }) {
if (progress.phase === 'idle' || progress.phase === 'unavailable') return null
function IndexingBadge({ progress, lastIndexedTime, onRetry, onReindex }: { progress: IndexingProgress; lastIndexedTime?: number | null; onRetry?: () => void; onReindex?: () => void }) {
const isIdle = progress.phase === 'idle' || progress.phase === 'unavailable'
// When idle, show "Indexed Xm ago" if we have a timestamp
if (isIdle) {
if (!lastIndexedTime) return null
const elapsed = formatIndexedElapsed(lastIndexedTime)
if (!elapsed) return null
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={onReindex ? 'button' : undefined}
onClick={onReindex}
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', cursor: onReindex ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
title={onReindex ? 'Click to reindex vault' : undefined}
data-testid="status-indexed-time"
onMouseEnter={onReindex ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={onReindex ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
>
<Search size={13} />{elapsed}
</span>
</>
)
}
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
const isActive = !progress.done
const isError = progress.phase === 'error'
@@ -329,7 +356,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
)
}
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, onCheckForUpdates, indexingProgress, onRetryIndexing, onRemoveVault, mcpStatus, onInstallMcp }: 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, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -355,7 +382,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PendingBadge count={modifiedCount} onClick={onClickPending} />
{indexingProgress && <IndexingBadge progress={indexingProgress} onRetry={onRetryIndexing} />}
{indexingProgress && <IndexingBadge progress={indexingProgress} lastIndexedTime={lastIndexedTime} onRetry={onRetryIndexing} onReindex={onReindexVault} />}
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>

114
src/hooks/useAIChat.test.ts Normal file
View File

@@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
// Capture what streamClaudeChat receives
const streamClaudeChatMock = vi.fn<
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
>()
vi.mock('../utils/ai-chat', async () => {
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
return {
...actual,
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
streamClaudeChatMock(...args)
// Simulate async: call onDone after a tick
const callbacks = args[3]
setTimeout(() => {
callbacks.onInit?.('test-session')
callbacks.onText('mock response')
callbacks.onDone()
}, 10)
return Promise.resolve('test-session')
},
}
})
import { useAIChat } from './useAIChat'
beforeEach(() => {
streamClaudeChatMock.mockClear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useAIChat', () => {
const emptyContent: Record<string, string> = {}
it('sends first message without history', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
act(() => { result.current.sendMessage('hello') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
const message = streamClaudeChatMock.mock.calls[0][0]
// First message: no history, so just the plain text
expect(message).toBe('hello')
})
it('includes conversation history in second message', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send first message
act(() => { result.current.sendMessage('What is Rust?') })
// Wait for mock response
await act(async () => { vi.advanceTimersByTime(50) })
// Now messages state has: [user: What is Rust?, assistant: mock response]
expect(result.current.messages).toHaveLength(2)
// Send second message
act(() => { result.current.sendMessage('Tell me more') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
const secondMessage = streamClaudeChatMock.mock.calls[1][0]
// Should contain conversation history
expect(secondMessage).toContain('What is Rust?')
expect(secondMessage).toContain('mock response')
expect(secondMessage).toContain('Tell me more')
})
it('resets history on clearConversation', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Send a message and get response
act(() => { result.current.sendMessage('hello') })
await act(async () => { vi.advanceTimersByTime(50) })
// Clear conversation
act(() => { result.current.clearConversation() })
expect(result.current.messages).toHaveLength(0)
// Send new message — should have no history
act(() => { result.current.sendMessage('fresh start') })
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
expect(lastCall[0]).toBe('fresh start')
})
it('accumulates multiple exchanges in history', async () => {
const { result } = renderHook(() => useAIChat(emptyContent, []))
// Exchange 1
act(() => { result.current.sendMessage('Q1') })
await act(async () => { vi.advanceTimersByTime(50) })
// Exchange 2
act(() => { result.current.sendMessage('Q2') })
await act(async () => { vi.advanceTimersByTime(50) })
// Exchange 3
act(() => { result.current.sendMessage('Q3') })
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
const thirdMessage = streamClaudeChatMock.mock.calls[2][0]
// Should contain all prior exchanges
expect(thirdMessage).toContain('Q1')
expect(thirdMessage).toContain('Q2')
expect(thirdMessage).toContain('Q3')
})
})

View File

@@ -7,6 +7,7 @@ import type { VaultEntry } from '../types'
import {
type ChatMessage, nextMessageId,
buildSystemPrompt, streamClaudeChat,
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
} from '../utils/ai-chat'
export function useAIChat(
@@ -29,9 +30,11 @@ export function useAIChat(
abortRef.current = false
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
const history = trimHistory(messages, MAX_HISTORY_TOKENS)
const messageWithHistory = formatMessageWithHistory(history, text.trim())
let accumulated = ''
streamClaudeChat(text.trim(), systemPrompt || undefined, sessionIdRef.current, {
streamClaudeChat(messageWithHistory, systemPrompt || undefined, sessionIdRef.current, {
onInit: (sid) => { sessionIdRef.current = sid },
onText: (chunk) => {
@@ -58,7 +61,7 @@ export function useAIChat(
}).then(sid => {
if (sid) sessionIdRef.current = sid
})
}, [isStreaming, allContent, contextNotes])
}, [isStreaming, allContent, contextNotes, messages])
const clearConversation = useCallback(() => {
abortRef.current = true

View File

@@ -66,6 +66,7 @@ interface AppCommandsConfig {
vaultCount?: number
mcpStatus?: string
onInstallMcp?: () => void
onReindexVault?: () => void
}
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
@@ -148,6 +149,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
onResolveConflicts: config.onResolveConflicts,
onViewChanges: viewChanges,
onInstallMcp: config.onInstallMcp,
onReindexVault: config.onReindexVault,
activeTabPathRef: config.activeTabPathRef,
handleCloseTabRef: config.handleCloseTabRef,
activeTabPath: config.activeTabPath,
@@ -201,6 +203,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
vaultCount: config.vaultCount,
mcpStatus: config.mcpStatus,
onInstallMcp: config.onInstallMcp,
onReindexVault: config.onReindexVault,
})
useKeyboardNavigation({

View File

@@ -255,6 +255,47 @@ describe('useAutoSync', () => {
expect(pullCalls).toHaveLength(0)
})
it('calls onSyncUpdated when pull has updates', async () => {
const onSyncUpdated = vi.fn()
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
return Promise.resolve(updated(['note.md']))
})
renderHook(() =>
useAutoSync({
vaultPath: '/Users/luca/Laputa',
intervalMinutes: 5,
onVaultUpdated,
onSyncUpdated,
onConflict,
onToast,
}),
)
await waitFor(() => {
expect(onSyncUpdated).toHaveBeenCalledOnce()
})
})
it('does not call onSyncUpdated when pull is up_to_date', async () => {
const onSyncUpdated = vi.fn()
renderHook(() =>
useAutoSync({
vaultPath: '/Users/luca/Laputa',
intervalMinutes: 5,
onVaultUpdated,
onSyncUpdated,
onConflict,
onToast,
}),
)
await waitFor(() => {
expect(onVaultUpdated).not.toHaveBeenCalled()
})
expect(onSyncUpdated).not.toHaveBeenCalled()
})
it('detects conflicts when git_pull returns error with unresolved conflicts', async () => {
mockInvokeFn.mockImplementation((cmd: string) => {
if (cmd === 'get_conflict_files') return Promise.resolve(['conflict.md'])

View File

@@ -13,6 +13,7 @@ interface UseAutoSyncOptions {
vaultPath: string
intervalMinutes: number | null
onVaultUpdated: () => void
onSyncUpdated?: () => void
onConflict: (files: string[]) => void
onToast: (msg: string) => void
}
@@ -33,6 +34,7 @@ export function useAutoSync({
vaultPath,
intervalMinutes,
onVaultUpdated,
onSyncUpdated,
onConflict,
onToast,
}: UseAutoSyncOptions): AutoSyncState {
@@ -42,8 +44,8 @@ export function useAutoSync({
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 callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
@@ -77,6 +79,7 @@ export function useAutoSync({
setSyncStatus('idle')
setConflictFiles([])
callbacksRef.current.onVaultUpdated()
callbacksRef.current.onSyncUpdated?.()
callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
} else if (result.status === 'conflict') {
setSyncStatus('conflict')

View File

@@ -95,6 +95,46 @@ describe('useCommandRegistry', () => {
expect(cmd!.enabled).toBe(false)
})
it('includes reindex-vault command in Settings group', () => {
const config = makeConfig({ onReindexVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd).toBeDefined()
expect(cmd!.group).toBe('Settings')
expect(cmd!.label).toBe('Reindex Vault')
})
it('reindex-vault is enabled when onReindexVault is provided', () => {
const config = makeConfig({ onReindexVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd!.enabled).toBe(true)
})
it('reindex-vault is disabled when onReindexVault is not provided', () => {
const config = makeConfig()
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd!.enabled).toBe(false)
})
it('reindex-vault executes onReindexVault callback', () => {
const onReindexVault = vi.fn()
const config = makeConfig({ onReindexVault })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
cmd!.execute()
expect(onReindexVault).toHaveBeenCalled()
})
it('reindex-vault has searchable keywords', () => {
const config = makeConfig({ onReindexVault: vi.fn() })
const { result } = renderHook(() => useCommandRegistry(config))
const cmd = findCommand(result.current, 'reindex-vault')
expect(cmd!.keywords).toContain('reindex')
expect(cmd!.keywords).toContain('search')
})
it('resolve-conflicts stays enabled across rerenders', () => {
const config = makeConfig()
const { result, rerender } = renderHook(

View File

@@ -20,6 +20,7 @@ interface CommandRegistryConfig {
modifiedCount: number
mcpStatus?: string
onInstallMcp?: () => void
onReindexVault?: () => void
onQuickOpen: () => void
onCreateNote: () => void
@@ -196,6 +197,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
onCreateType,
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onReindexVault,
} = config
const hasActiveNote = activeTabPath !== null
@@ -257,6 +259,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
{ 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: true, execute: () => onCheckForUpdates?.() },
{ id: 'install-mcp', label: 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install'], enabled: mcpStatus === 'not_installed' && !!onInstallMcp, execute: () => onInstallMcp?.() },
{ id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
// Type-aware: "New [Type]" and "List [Type]"
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
@@ -275,5 +278,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
mcpStatus, onInstallMcp,
onReindexVault,
])
}

View File

@@ -25,8 +25,8 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
relationships: {},
icon: null,
color: null,
order: null,
template: null, sort: null,
order: null, sidebarLabel: null,
template: null, sort: null, view: null, visible: null,
outgoingLinks: [],
properties: {},
...overrides,

View File

@@ -84,4 +84,62 @@ describe('useIndexing', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(typeof result.current.retryIndexing).toBe('function')
})
it('exposes triggerFullReindex function', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(typeof result.current.triggerFullReindex).toBe('function')
})
it('retryIndexing is the same reference as triggerFullReindex', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.retryIndexing).toBe(result.current.triggerFullReindex)
})
it('triggerFullReindex sets scanning phase then completes', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
await act(async () => { await result.current.triggerFullReindex() })
// In non-Tauri mode, it goes to 'complete' then auto-dismisses
expect(result.current.progress.phase).toBe('complete')
})
it('triggerFullReindex sets lastIndexedTime on success', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.lastIndexedTime).toBeNull()
await act(async () => { await result.current.triggerFullReindex() })
expect(result.current.lastIndexedTime).toBeGreaterThan(0)
})
it('triggerFullReindex sets error phase on failure', async () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
mockInvoke.mockRejectedValueOnce(new Error('indexing failed'))
await act(async () => { await result.current.triggerFullReindex() })
expect(result.current.progress.phase).toBe('error')
})
it('populates lastIndexedTime from backend metadata on mount', async () => {
mockInvoke.mockResolvedValue({
available: true,
qmd_installed: true,
collection_exists: true,
indexed_count: 100,
embedded_count: 80,
pending_embed: 0,
last_indexed_commit: 'abc123',
last_indexed_at: 1709800000,
})
const { result } = renderHook(() => useIndexing('/test/vault'))
// Wait for the effect to run
await act(async () => { await vi.advanceTimersByTimeAsync(10) })
expect(result.current.lastIndexedTime).toBe(1709800000000) // seconds → ms
})
it('starts with lastIndexedTime as null', () => {
const { result } = renderHook(() => useIndexing('/test/vault'))
expect(result.current.lastIndexedTime).toBeNull()
})
})

View File

@@ -17,6 +17,8 @@ interface IndexStatus {
indexed_count: number
embedded_count: number
pending_embed: number
last_indexed_commit: string | null
last_indexed_at: number | null
}
const IDLE: IndexingProgress = { phase: 'idle', current: 0, total: 0, done: false, error: null }
@@ -27,6 +29,7 @@ function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
export function useIndexing(vaultPath: string) {
const [progress, setProgress] = useState<IndexingProgress>(IDLE)
const [lastIndexedTime, setLastIndexedTime] = useState<number | null>(null)
const indexingRef = useRef(false)
const vaultPathRef = useRef(vaultPath)
@@ -43,6 +46,9 @@ export function useIndexing(vaultPath: string) {
setProgress(event.payload)
if (event.payload.done) {
indexingRef.current = false
if (event.payload.phase === 'complete') {
setLastIndexedTime(Date.now())
}
}
})
cleanup = () => { unlisten.then(fn => fn()) }
@@ -61,6 +67,11 @@ export function useIndexing(vaultPath: string) {
const status = await invokeCmd<IndexStatus>('get_index_status', { vaultPath })
if (cancelled) return
// Populate last indexed time from backend metadata
if (status.last_indexed_at) {
setLastIndexedTime(status.last_indexed_at * 1000) // seconds → ms
}
// 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) {
@@ -116,17 +127,23 @@ export function useIndexing(vaultPath: string) {
if (indexingRef.current) return
try {
await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current })
setLastIndexedTime(Date.now())
} catch {
// Incremental update failure is non-fatal
}
}, [])
const retryIndexing = useCallback(async () => {
const triggerFullReindex = useCallback(async () => {
if (indexingRef.current || !vaultPathRef.current) return
indexingRef.current = true
setProgress({ phase: 'scanning', current: 0, total: 0, done: false, error: null })
try {
await invokeCmd('start_indexing', { vaultPath: vaultPathRef.current })
// In non-Tauri mode, mark complete immediately
if (!isTauri()) {
setProgress({ phase: 'complete', current: 0, total: 0, done: true, error: null })
setLastIndexedTime(Date.now())
}
} catch (err) {
const msg = String(err)
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
@@ -136,5 +153,7 @@ export function useIndexing(vaultPath: string) {
}
}, [])
return { progress, triggerIncrementalIndex, retryIndexing }
const retryIndexing = triggerFullReindex
return { progress, lastIndexedTime, triggerIncrementalIndex, triggerFullReindex, retryIndexing }
}

View File

@@ -34,6 +34,7 @@ function makeHandlers(): MenuEventHandlers {
onResolveConflicts: vi.fn(),
onViewChanges: vi.fn(),
onInstallMcp: vi.fn(),
onReindexVault: 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',
@@ -299,6 +300,12 @@ describe('dispatchMenuEvent', () => {
expect(h.onInstallMcp).toHaveBeenCalled()
})
it('vault-reindex triggers reindex vault', () => {
const h = makeHandlers()
dispatchMenuEvent('vault-reindex', h)
expect(h.onReindexVault).toHaveBeenCalled()
})
// Edge cases
it('unknown event ID does nothing', () => {
const h = makeHandlers()

View File

@@ -35,6 +35,7 @@ export interface MenuEventHandlers {
onResolveConflicts?: () => void
onViewChanges?: () => void
onInstallMcp?: () => void
onReindexVault?: () => void
activeTabPathRef: React.MutableRefObject<string | null>
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
activeTabPath: string | null
@@ -77,7 +78,7 @@ type OptionalHandler =
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
| 'onCreateTheme' | 'onRestoreDefaultThemes'
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp'
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault'
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'view-go-back': 'onGoBack',
@@ -96,6 +97,7 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
'vault-resolve-conflicts': 'onResolveConflicts',
'vault-view-changes': 'onViewChanges',
'vault-install-mcp': 'onInstallMcp',
'vault-reindex': 'onReindexVault',
}
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {

View File

@@ -290,6 +290,8 @@ describe('frontmatterToEntryPatch', () => {
['trashed', true, { trashed: true }],
['order', 5, { order: 5 }],
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
['visible', false, { visible: false }],
['visible', true, { visible: null }],
] as [string, unknown, Partial<VaultEntry>][])(
'maps %s update to correct entry field',
(key, value, expected) => {
@@ -321,6 +323,7 @@ describe('frontmatterToEntryPatch', () => {
['archived', { archived: false }],
['order', { order: null }],
['template', { template: null }],
['visible', { visible: null }],
] as [string, Partial<VaultEntry>][])(
'maps delete of %s to null/default',
(key, expected) => {

View File

@@ -150,7 +150,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 }, sort: { sort: null },
template: { template: null }, sort: { sort: null }, visible: { visible: null },
}
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
@@ -170,6 +170,7 @@ export function frontmatterToEntryPatch(
template: { template: str },
sort: { sort: str },
view: { view: str },
visible: { visible: value === false ? false : null },
}
return updates[k] ?? {}
}

View File

@@ -34,7 +34,7 @@ function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md')
if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${(args as Record<string, string>)?.commitHash}`)
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve('pushed')
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
return Promise.resolve(null)
}
@@ -382,6 +382,49 @@ describe('useVaultLoader', () => {
expect(response).toBe('Committed and pushed')
})
it('returns actionable message when push is rejected', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' })
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
let response = ''
await act(async () => {
response = await result.current.commitAndPush('test commit')
})
expect(response).toContain('Pull first')
expect(response).not.toBe('Committed and pushed')
})
it('returns network error message on network failure', async () => {
mockInvokeFn.mockImplementation(((cmd: string) => {
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
if (cmd === 'get_modified_files') return Promise.resolve([])
if (cmd === 'git_commit') return Promise.resolve('committed')
if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' })
return Promise.resolve(null)
}) as typeof defaultMockInvoke)
const { result } = renderHook(() => useVaultLoader('/vault'))
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
let response = ''
await act(async () => {
response = await result.current.commitAndPush('test commit')
})
expect(response).toContain('network error')
})
})
describe('loadModifiedFiles', () => {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState, startTransition } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus } from '../types'
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
@@ -18,16 +18,12 @@ async function loadVaultData(vaultPath: string) {
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
if (!isTauri()) {
await mockInvoke<string>('git_commit', { message })
await mockInvoke<string>('git_push', {})
return 'Committed and pushed'
const pushResult = await mockInvoke<GitPushResult>('git_push', {})
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
}
await invoke<string>('git_commit', { vaultPath, message })
try {
await invoke<string>('git_push', { vaultPath })
return 'Committed and pushed'
} catch {
return 'Committed (push failed)'
}
const pushResult = await invoke<GitPushResult>('git_push', { vaultPath })
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
}
function useNewNoteTracker() {

View File

@@ -14,9 +14,10 @@ export function isTauri(): boolean {
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
}
// Initialize window.__mockContent for browser testing
// Initialize window globals for browser testing and Playwright overrides
if (typeof window !== 'undefined') {
window.__mockContent = MOCK_CONTENT
window.__mockHandlers = mockHandlers
}
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {

View File

@@ -3,7 +3,7 @@
* Each handler simulates a Tauri backend command.
*/
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
import { MOCK_CONTENT } from './mock-content'
import { MOCK_ENTRIES } from './mock-entries'
@@ -172,7 +172,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
get_build_number: () => 'bDEV',
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
git_push: () => 'Everything up-to-date',
git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }),
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
const limit = args.limit ?? 30
const ts = Math.floor(Date.now() / 1000)
@@ -277,7 +277,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0 }),
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0, last_indexed_commit: 'abc123', last_indexed_at: Math.floor(Date.now() / 1000) - 3600 }),
start_indexing: () => null,
trigger_incremental_index: () => null,
list_themes: (): ThemeFile[] => [...mockThemes],

View File

@@ -78,6 +78,11 @@ export interface GitPullResult {
conflictFiles: string[]
}
export interface GitPushResult {
status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'error'
message: string
}
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict'
export interface DeviceFlowStart {

View File

@@ -8,6 +8,8 @@ vi.mock('../mock-tauri', () => ({
import {
estimateTokens, buildSystemPrompt,
nextMessageId, checkClaudeCli, streamClaudeChat,
trimHistory, formatMessageWithHistory,
type ChatMessage, MAX_HISTORY_TOKENS,
} from './ai-chat'
import type { VaultEntry } from '../types'
@@ -73,6 +75,107 @@ describe('checkClaudeCli', () => {
})
})
// --- trimHistory ---
describe('trimHistory', () => {
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
role, content, id: `msg-${content}`,
})
it('returns empty array for empty history', () => {
expect(trimHistory([], 1000)).toEqual([])
})
it('returns all messages when under token limit', () => {
const history = [msg('user', 'hi'), msg('assistant', 'hello')]
expect(trimHistory(history, 1000)).toEqual(history)
})
it('drops oldest messages when over token limit', () => {
const history = [
msg('user', 'a'.repeat(400)), // 100 tokens
msg('assistant', 'b'.repeat(400)), // 100 tokens
msg('user', 'c'.repeat(400)), // 100 tokens
]
const result = trimHistory(history, 200)
// Should keep the two most recent messages (200 tokens)
expect(result).toHaveLength(2)
expect(result[0].content).toBe('b'.repeat(400))
expect(result[1].content).toBe('c'.repeat(400))
})
it('keeps at least one message if it fits', () => {
const history = [
msg('user', 'a'.repeat(2000)), // 500 tokens
msg('assistant', 'b'.repeat(80)), // 20 tokens
]
const result = trimHistory(history, 30)
expect(result).toHaveLength(1)
expect(result[0].content).toBe('b'.repeat(80))
})
it('returns empty when single message exceeds limit', () => {
const history = [msg('user', 'a'.repeat(4000))] // 1000 tokens
expect(trimHistory(history, 10)).toEqual([])
})
})
// --- formatMessageWithHistory ---
describe('formatMessageWithHistory', () => {
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
role, content, id: `msg-${content}`,
})
it('returns bare message when no history', () => {
expect(formatMessageWithHistory([], 'hello')).toBe('hello')
})
it('includes conversation history before the new message', () => {
const history = [msg('user', 'What is Rust?'), msg('assistant', 'A systems language.')]
const result = formatMessageWithHistory(history, 'How does it compare to Go?')
expect(result).toContain('What is Rust?')
expect(result).toContain('A systems language.')
expect(result).toContain('How does it compare to Go?')
})
it('labels user and assistant messages correctly', () => {
const history = [msg('user', 'Q1'), msg('assistant', 'A1')]
const result = formatMessageWithHistory(history, 'Q2')
expect(result).toContain('[user]: Q1')
expect(result).toContain('[assistant]: A1')
expect(result).toContain('[user]: Q2')
})
it('preserves message order', () => {
const history = [
msg('user', 'first'),
msg('assistant', 'second'),
msg('user', 'third'),
msg('assistant', 'fourth'),
]
const result = formatMessageWithHistory(history, 'fifth')
const firstIdx = result.indexOf('first')
const secondIdx = result.indexOf('second')
const thirdIdx = result.indexOf('third')
const fourthIdx = result.indexOf('fourth')
const fifthIdx = result.indexOf('fifth')
expect(firstIdx).toBeLessThan(secondIdx)
expect(secondIdx).toBeLessThan(thirdIdx)
expect(thirdIdx).toBeLessThan(fourthIdx)
expect(fourthIdx).toBeLessThan(fifthIdx)
})
})
// --- MAX_HISTORY_TOKENS ---
describe('MAX_HISTORY_TOKENS', () => {
it('is a reasonable token limit', () => {
expect(MAX_HISTORY_TOKENS).toBeGreaterThan(10_000)
expect(MAX_HISTORY_TOKENS).toBeLessThan(200_000)
})
})
// --- streamClaudeChat ---
describe('streamClaudeChat', () => {

View File

@@ -76,6 +76,34 @@ export function nextMessageId(): string {
return `msg-${++msgIdCounter}-${Date.now()}`
}
// --- Conversation history ---
/** Max tokens of history to include in each request. */
export const MAX_HISTORY_TOKENS = 100_000
/** Keep the most recent messages that fit within `maxTokens`. Drops oldest first. */
export function trimHistory(history: ChatMessage[], maxTokens: number): ChatMessage[] {
let tokenCount = 0
const result: ChatMessage[] = []
for (let i = history.length - 1; i >= 0; i--) {
const tokens = estimateTokens(history[i].content)
if (tokenCount + tokens > maxTokens) break
result.unshift(history[i])
tokenCount += tokens
}
return result
}
/** Format conversation history + new message into a single prompt for the CLI. */
export function formatMessageWithHistory(history: ChatMessage[], newMessage: string): string {
if (history.length === 0) return newMessage
const lines = history.map(m => `[${m.role}]: ${m.content}`)
lines.push(`[user]: ${newMessage}`)
return `<conversation_history>\n${lines.join('\n\n')}\n</conversation_history>\n\nContinue the conversation. Respond only to the latest [user] message.`
}
// --- Claude CLI status ---
export interface ClaudeCliStatus {

View File

@@ -0,0 +1,10 @@
export function formatIndexedElapsed(lastIndexedTime: number | null): string {
if (!lastIndexedTime) return ''
const secs = Math.round((Date.now() - lastIndexedTime) / 1000)
if (secs < 60) return 'Indexed just now'
const mins = Math.floor(secs / 60)
if (mins < 60) return `Indexed ${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `Indexed ${hrs}h ago`
return `Indexed ${Math.floor(hrs / 24)}d ago`
}

View File

@@ -0,0 +1,71 @@
import { test, expect } from '@playwright/test'
import { openCommandPalette, executeCommand } from './helpers'
test.describe('Sync error UX — actionable push error messages', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('rejected push shows actionable "Pull first" message in toast', async ({ page }) => {
// Override git_push mock to return a rejected result
await page.evaluate(() => {
window.__mockHandlers!.git_push = () => ({
status: 'rejected',
message: 'Push rejected: remote has new commits. Pull first, then push.',
})
})
// Open commit dialog via command palette
await openCommandPalette(page)
await executeCommand(page, 'Commit & Push')
// Wait for the CommitDialog textarea
const textarea = page.locator('textarea[placeholder="Commit message..."]')
await textarea.waitFor({ timeout: 5000 })
await textarea.fill('test commit')
// Click the "Commit & Push" button in the dialog
const commitButton = page.getByRole('button', { name: 'Commit & Push' })
await commitButton.click()
// Verify the toast shows the actionable rejection message
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Pull first', { timeout: 5000 })
})
test('auth error push shows authentication message in toast', async ({ page }) => {
await page.evaluate(() => {
window.__mockHandlers!.git_push = () => ({
status: 'auth_error',
message: 'Push failed: authentication error. Check your credentials.',
})
})
await openCommandPalette(page)
await executeCommand(page, 'Commit & Push')
const textarea = page.locator('textarea[placeholder="Commit message..."]')
await textarea.waitFor({ timeout: 5000 })
await textarea.fill('test commit')
await page.getByRole('button', { name: 'Commit & Push' }).click()
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('authentication error', { timeout: 5000 })
})
test('successful push shows normal success message', async ({ page }) => {
await openCommandPalette(page)
await executeCommand(page, 'Commit & Push')
const textarea = page.locator('textarea[placeholder="Commit message..."]')
await textarea.waitFor({ timeout: 5000 })
await textarea.fill('test commit')
await page.getByRole('button', { name: 'Commit & Push' }).click()
const toast = page.locator('.fixed.bottom-8')
await expect(toast).toContainText('Committed and pushed', { timeout: 5000 })
})
})

View File

@@ -0,0 +1,28 @@
import { test, expect } from '@playwright/test'
import {
openCommandPalette,
findCommand,
executeCommand,
} from './helpers'
test.describe('Reindex Vault smoke tests', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('Reindex Vault command appears in command palette', async ({ page }) => {
await openCommandPalette(page)
const found = await findCommand(page, 'Reindex Vault')
expect(found).toBe(true)
})
test('Reindex Vault command triggers indexing progress', async ({ page }) => {
await openCommandPalette(page)
await executeCommand(page, 'Reindex Vault')
// After triggering reindex, the indexing badge should appear in the status bar
const indexingBadge = page.locator('[data-testid="status-indexing"], [data-testid="status-indexed-time"]')
await expect(indexingBadge.first()).toBeVisible({ timeout: 5_000 })
})
})