Compare commits
20 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c37c03d6a9 | ||
|
|
fcc264d7dc | ||
|
|
382ba0a6d4 | ||
|
|
4719810b10 | ||
|
|
20b4ba7a3b | ||
|
|
de00ab6794 | ||
|
|
5d8f514bea | ||
|
|
1fd3ea02ae | ||
|
|
8da1484ebf | ||
|
|
90ebc2e939 | ||
|
|
c14927df8f | ||
|
|
a6d60695a2 | ||
|
|
7ddc0c14bf | ||
|
|
2a00b8aac7 | ||
|
|
b7d2304282 | ||
|
|
50b5fa9c2e | ||
|
|
963e7cf111 | ||
|
|
c9a5d20c12 | ||
|
|
586e1fcde5 | ||
|
|
75d67623ce |
@@ -1 +1 @@
|
||||
33549
|
||||
66070
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* - get_vault_context: vault structure overview (types, note count, folders)
|
||||
* - get_note: parsed frontmatter + content (convenience over raw cat)
|
||||
* - open_note: signal Laputa UI to open a note as a tab
|
||||
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
|
||||
* - refresh_vault: trigger vault rescan so new/modified files appear
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
@@ -94,6 +96,28 @@ const TOOLS = [
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'highlight_editor',
|
||||
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' },
|
||||
path: { type: 'string', description: 'Optional note path to associate with the highlight' },
|
||||
},
|
||||
required: ['element'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refresh_vault',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Optional specific note path that changed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
@@ -101,6 +125,8 @@ const TOOL_HANDLERS = {
|
||||
get_vault_context: handleVaultContext,
|
||||
get_note: handleGetNote,
|
||||
open_note: handleOpenNote,
|
||||
highlight_editor: handleHighlightEditor,
|
||||
refresh_vault: handleRefreshVault,
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
@@ -126,10 +152,20 @@ function handleOpenNote(args) {
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
|
||||
}
|
||||
|
||||
function handleHighlightEditor(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
|
||||
}
|
||||
|
||||
function handleRefreshVault(args) {
|
||||
broadcastUiAction('vault_changed', { path: args?.path })
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'laputa-mcp-server', version: '0.2.0' },
|
||||
{ name: 'laputa-mcp-server', version: '0.3.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
|
||||
let tmpDir
|
||||
@@ -65,39 +64,23 @@ describe('findMarkdownFiles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNote', () => {
|
||||
it('should read a note by relative path', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('Test Project'))
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
describe('getNote', () => {
|
||||
it('should read a note with parsed frontmatter', async () => {
|
||||
const note = await getNote(tmpDir, 'project/test-project.md')
|
||||
assert.equal(note.path, 'project/test-project.md')
|
||||
assert.equal(note.frontmatter.title, 'Test Project')
|
||||
assert.equal(note.frontmatter.is_a, 'Project')
|
||||
assert.ok(note.content.includes('test project for the MCP server'))
|
||||
})
|
||||
|
||||
it('should throw for missing notes', async () => {
|
||||
await assert.rejects(
|
||||
() => readNote(tmpDir, 'nonexistent.md'),
|
||||
() => getNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should create a note with frontmatter', async () => {
|
||||
const absPath = await createNote(tmpDir, 'note/new-note.md', 'My New Note', { is_a: 'Note' })
|
||||
assert.ok(absPath.endsWith('new-note.md'))
|
||||
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('title: My New Note'))
|
||||
assert.ok(content.includes('is_a: Note'))
|
||||
assert.ok(content.includes('# My New Note'))
|
||||
})
|
||||
|
||||
it('should create parent directories', async () => {
|
||||
const absPath = await createNote(tmpDir, 'deep/nested/dir/note.md', 'Deep Note')
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('# Deep Note'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should find notes matching title', async () => {
|
||||
const results = await searchNotes(tmpDir, 'Test Project')
|
||||
@@ -121,123 +104,6 @@ describe('searchNotes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendToNote', () => {
|
||||
it('should append text to a note', async () => {
|
||||
await appendToNote(tmpDir, 'note/daily-log.md', '## Evening Update\nFinished testing.')
|
||||
const content = await readNote(tmpDir, 'note/daily-log.md')
|
||||
assert.ok(content.includes('## Evening Update'))
|
||||
assert.ok(content.includes('Finished testing.'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('editNoteFrontmatter', () => {
|
||||
it('should merge a patch into frontmatter', async () => {
|
||||
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
|
||||
assert.equal(updated.status, 'Completed')
|
||||
assert.equal(updated.priority, 'High')
|
||||
assert.equal(updated.title, 'Test Project')
|
||||
})
|
||||
|
||||
it('should preserve existing frontmatter fields', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
assert.ok(content.includes('status: Completed'))
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('should delete an existing note', async () => {
|
||||
const delPath = 'note/to-delete.md'
|
||||
await createNote(tmpDir, delPath, 'To Delete')
|
||||
const absPath = path.join(tmpDir, delPath)
|
||||
|
||||
// Verify it exists
|
||||
await fs.access(absPath)
|
||||
|
||||
await deleteNote(tmpDir, delPath)
|
||||
|
||||
await assert.rejects(
|
||||
() => fs.access(absPath),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => deleteNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkNotes', () => {
|
||||
it('should add a target to an array property', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
|
||||
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.deepEqual(result, ['[[note/daily-log]]'])
|
||||
})
|
||||
|
||||
it('should not duplicate existing links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.equal(result.length, 1)
|
||||
})
|
||||
|
||||
it('should add multiple distinct links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
// Should have daily-log and test-project
|
||||
assert.ok(result.includes('[[note/daily-log]]'))
|
||||
assert.ok(result.includes('[[project/test-project]]'))
|
||||
assert.equal(result.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listNotes', () => {
|
||||
it('should list all notes sorted by title', async () => {
|
||||
const notes = await listNotes(tmpDir)
|
||||
assert.ok(notes.length >= 3)
|
||||
// Verify sorted by title
|
||||
for (let i = 1; i < notes.length; i++) {
|
||||
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('should filter by type', async () => {
|
||||
const projects = await listNotes(tmpDir, 'Project')
|
||||
assert.ok(projects.length >= 1)
|
||||
for (const n of projects) {
|
||||
assert.equal(n.type, 'Project')
|
||||
}
|
||||
})
|
||||
|
||||
it('should return empty for unknown type', async () => {
|
||||
const notes = await listNotes(tmpDir, 'UnknownType12345')
|
||||
assert.equal(notes.length, 0)
|
||||
})
|
||||
|
||||
it('should support mtime sorting', async () => {
|
||||
const notes = await listNotes(tmpDir, undefined, 'mtime')
|
||||
assert.ok(notes.length >= 1)
|
||||
// Just verify it returns results without crashing
|
||||
assert.ok(notes[0].path)
|
||||
assert.ok(notes[0].title)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vaultContext', () => {
|
||||
it('should return types, recent notes, and vault path', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
@@ -264,4 +130,15 @@ describe('vaultContext', () => {
|
||||
assert.ok(note.title)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include folders', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(ctx.folders.includes('project/'))
|
||||
assert.ok(ctx.folders.includes('note/'))
|
||||
})
|
||||
|
||||
it('should report correct note count', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.equal(ctx.noteCount, 3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,6 +50,8 @@ const TOOL_HANDLERS = {
|
||||
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
|
||||
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -54,7 +54,11 @@ fn parse_file_status(code: &str) -> &str {
|
||||
|
||||
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
|
||||
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
|
||||
pub fn get_vault_pulse(vault_path: &str, limit: usize, skip: usize) -> Result<Vec<PulseCommit>, String> {
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: &str,
|
||||
limit: usize,
|
||||
skip: usize,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !vault.join(".git").exists() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ const VIEW_GO_BACK: &str = "view-go-back";
|
||||
const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
|
||||
const GO_ALL_NOTES: &str = "go-all-notes";
|
||||
const GO_FAVORITES: &str = "go-favorites";
|
||||
const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_TRASH: &str = "go-trash";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
@@ -49,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,
|
||||
@@ -75,7 +75,6 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
@@ -90,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.
|
||||
@@ -249,9 +249,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
let all_notes = MenuItemBuilder::new("All Notes")
|
||||
.id(GO_ALL_NOTES)
|
||||
.build(app)?;
|
||||
let favorites = MenuItemBuilder::new("Favorites")
|
||||
.id(GO_FAVORITES)
|
||||
.build(app)?;
|
||||
let archived = MenuItemBuilder::new("Archived")
|
||||
.id(GO_ARCHIVED)
|
||||
.build(app)?;
|
||||
@@ -268,7 +265,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Go")
|
||||
.item(&all_notes)
|
||||
.item(&favorites)
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
@@ -338,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)
|
||||
@@ -351,6 +350,7 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
.item(&resolve_conflicts)
|
||||
.item(&view_changes)
|
||||
.separator()
|
||||
.item(&reindex)
|
||||
.item(&install_mcp)
|
||||
.build()?)
|
||||
}
|
||||
@@ -454,7 +454,6 @@ mod tests {
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
@@ -469,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}");
|
||||
|
||||
@@ -158,10 +158,7 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
|
||||
/// Machine-local files that should never be git-tracked in any vault.
|
||||
/// These are either caches with absolute paths or per-machine settings.
|
||||
const UNTRACKED_FILES: &[&str] = &[
|
||||
".laputa-cache.json",
|
||||
".laputa/settings.json",
|
||||
];
|
||||
const UNTRACKED_FILES: &[&str] = &[".laputa-cache.json", ".laputa/settings.json"];
|
||||
|
||||
/// Ensure machine-local files are excluded from git via `.git/info/exclude`
|
||||
/// and un-tracked if they were previously committed (git rm --cached).
|
||||
@@ -184,7 +181,11 @@ fn ensure_cache_excluded(vault: &Path) {
|
||||
|
||||
if !to_add.is_empty() {
|
||||
to_add.sort();
|
||||
let separator = if existing.ends_with('\n') || existing.is_empty() { "" } else { "\n" };
|
||||
let separator = if existing.ends_with('\n') || existing.is_empty() {
|
||||
""
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
let additions = to_add.join("\n");
|
||||
let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n"));
|
||||
}
|
||||
@@ -645,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('App', () => {
|
||||
await waitFor(() => {
|
||||
// "All Notes" should be rendered as the selected nav item
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.getByText('Archive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
42
src/App.tsx
42
src/App.tsx
@@ -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: () => {
|
||||
@@ -230,19 +233,26 @@ function App() {
|
||||
|
||||
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
|
||||
|
||||
// O(1) path lookup map — rebuilt only when vault.entries changes
|
||||
const entriesByPath = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry>()
|
||||
for (const e of vault.entries) map.set(e.path, e)
|
||||
return map
|
||||
}, [vault.entries])
|
||||
|
||||
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
|
||||
const openNoteByPath = useCallback((path: string) => {
|
||||
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
const entry = entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
|
||||
if (entry) {
|
||||
notes.handleSelectNote(entry)
|
||||
} else {
|
||||
// Entry not yet in vault (just created) — reload then open
|
||||
vault.reloadVault().then(freshEntries => {
|
||||
const fresh = freshEntries.find((e: VaultEntry) => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
const fresh = (freshEntries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
if (fresh) notes.handleSelectNote(fresh)
|
||||
})
|
||||
}
|
||||
}, [vault, notes, resolvedPath])
|
||||
}, [entriesByPath, vault, notes, resolvedPath])
|
||||
|
||||
const aiActivity = useAiActivity({
|
||||
onOpenNote: openNoteByPath,
|
||||
@@ -253,10 +263,18 @@ function App() {
|
||||
onVaultChanged: () => { vault.reloadVault() },
|
||||
})
|
||||
|
||||
// Stable callback for Pulse "open note" — never triggers reloadVault.
|
||||
// Pulse files always exist in the vault; if somehow not found, silently skip.
|
||||
const handlePulseOpenNote = useCallback((relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}, [entriesByPath, resolvedPath, notes])
|
||||
|
||||
// Agent file operation handlers: auto-open created notes, live-refresh modified notes
|
||||
const handleAgentFileCreated = useCallback((relativePath: string) => {
|
||||
vault.reloadVault().then(freshEntries => {
|
||||
const entry = freshEntries.find((e: VaultEntry) => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
|
||||
const entry = (freshEntries as VaultEntry[]).find(e => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
})
|
||||
}, [vault, notes, resolvedPath])
|
||||
@@ -315,6 +333,7 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
})
|
||||
|
||||
const handleDeleteNote = useCallback(async (path: string) => {
|
||||
@@ -448,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
|
||||
@@ -509,11 +529,7 @@ function App() {
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={(relativePath) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = vault.entries.find(e => e.path === fullPath || e.path === relativePath)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<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} />
|
||||
)}
|
||||
@@ -573,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} />
|
||||
|
||||
@@ -18,6 +18,7 @@ interface AIChatPanelProps {
|
||||
allContent: Record<string, string>
|
||||
entries?: VaultEntry[]
|
||||
onClose: () => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function TypingIndicator() {
|
||||
@@ -99,10 +100,10 @@ function ContextSearchDropdown({
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
|
||||
function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<MarkdownContent content={msg.content} />
|
||||
<MarkdownContent content={msg.content} onWikilinkClick={onNavigateWikilink} />
|
||||
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
|
||||
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
|
||||
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
|
||||
@@ -121,10 +122,10 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingContent({ content }: { content: string }) {
|
||||
function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<MarkdownContent content={content} />
|
||||
<MarkdownContent content={content} onWikilinkClick={onNavigateWikilink} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -176,7 +177,7 @@ function useContextNotes(entry: VaultEntry | null) {
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) {
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
@@ -213,7 +214,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
<MessageList
|
||||
messages={chat.messages} isStreaming={chat.isStreaming}
|
||||
streamingContent={chat.streamingContent} onRetry={chat.retryMessage}
|
||||
messagesEndRef={messagesEndRef}
|
||||
messagesEndRef={messagesEndRef} onNavigateWikilink={onNavigateWikilink}
|
||||
/>
|
||||
|
||||
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
|
||||
@@ -284,10 +285,11 @@ function ContextBar({
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages, isStreaming, streamingContent, onRetry, messagesEndRef,
|
||||
messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink,
|
||||
}: {
|
||||
messages: ChatMessage[]; isStreaming: boolean; streamingContent: string
|
||||
onRetry: (idx: number) => void; messagesEndRef: React.RefObject<HTMLDivElement | null>
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
@@ -302,10 +304,10 @@ function MessageList({
|
||||
<div key={msg.id} style={{ marginBottom: 12 }}>
|
||||
{msg.role === 'user'
|
||||
? <UserBubble content={msg.content} />
|
||||
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} />}
|
||||
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
|
||||
</div>
|
||||
))}
|
||||
{isStreaming && streamingContent && <StreamingContent content={streamingContent} />}
|
||||
{isStreaming && streamingContent && <StreamingContent content={streamingContent} onNavigateWikilink={onNavigateWikilink} />}
|
||||
{isStreaming && !streamingContent && <TypingIndicator />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface AiMessageProps {
|
||||
response?: string
|
||||
isStreaming?: boolean
|
||||
onOpenNote?: (path: string) => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function ReferencePill({ reference, onClick }: {
|
||||
@@ -147,10 +148,10 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ResponseBlock({ text }: { text: string }) {
|
||||
function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<MarkdownContent content={text} />
|
||||
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
|
||||
<button
|
||||
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
style={{ fontSize: 11, marginTop: 4 }}
|
||||
@@ -175,7 +176,7 @@ function StreamingIndicator() {
|
||||
)
|
||||
}
|
||||
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
|
||||
// Manual override: null = follow auto behavior, true/false = user forced
|
||||
const [userOverride, setUserOverride] = useState(false)
|
||||
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
|
||||
@@ -212,7 +213,7 @@ export function AiMessage({ userMessage, references, reasoning, reasoningDone, a
|
||||
onToggleExpand={toggleAction}
|
||||
/>
|
||||
)}
|
||||
{response && <ResponseBlock text={response} />}
|
||||
{response && <ResponseBlock text={response} onNavigateWikilink={onNavigateWikilink} />}
|
||||
{isStreaming && !response && <StreamingIndicator />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AiMessage } from './AiMessage'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { useAiAgent, type AiAgentMessage, type AgentFileCallbacks } from '../hooks/useAiAgent'
|
||||
import { collectLinkedEntries, buildContextSnapshot, type NoteReference, type NoteListItem } from '../utils/ai-context'
|
||||
import { findEntryByTarget } from '../utils/wikilinkColors'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
export type { AiAgentMessage } from '../hooks/useAiAgent'
|
||||
@@ -89,8 +90,8 @@ function EmptyState({ hasContext }: { hasContext: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
|
||||
function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -102,7 +103,7 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
|
||||
{messages.map((msg, i) => (
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} onNavigateWikilink={onNavigateWikilink} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
@@ -167,6 +168,12 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
if (!entries) return
|
||||
const entry = findEntryByTarget(entries, target)
|
||||
if (entry) onOpenNote?.(entry.path)
|
||||
}, [entries, onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
@@ -198,6 +205,7 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
messages={agent.messages}
|
||||
isActive={isActive}
|
||||
onOpenNote={onOpenNote}
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
import { preprocessWikilinks } from '../utils/chatWikilinks'
|
||||
|
||||
describe('MarkdownContent', () => {
|
||||
it('renders bold text', () => {
|
||||
@@ -72,4 +73,110 @@ describe('MarkdownContent', () => {
|
||||
expect(bq).toBeTruthy()
|
||||
expect(bq!.textContent).toContain('A quote')
|
||||
})
|
||||
|
||||
describe('wikilinks', () => {
|
||||
it('preprocessWikilinks converts [[Target]] to markdown links', () => {
|
||||
expect(preprocessWikilinks('See [[My Note]]')).toBe('See [My Note](wikilink://My%20Note)')
|
||||
expect(preprocessWikilinks('[[A]] and [[B]]')).toBe('[A](wikilink://A) and [B](wikilink://B)')
|
||||
expect(preprocessWikilinks('`[[code]]`')).toBe('`[[code]]`')
|
||||
})
|
||||
|
||||
it('renders [[Note Title]] as a clickable wikilink chip', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Check out [[My Note]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')
|
||||
expect(wikilink).toBeTruthy()
|
||||
expect(wikilink!.textContent).toBe('My Note')
|
||||
expect(wikilink!.getAttribute('data-wikilink-target')).toBe('My Note')
|
||||
})
|
||||
|
||||
it('fires onWikilinkClick when a wikilink is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Daily Log]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('Daily Log')
|
||||
})
|
||||
|
||||
it('renders multiple wikilinks in the same paragraph', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Note A]] and [[Note B]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
expect(wikilinks[0].textContent).toBe('Note A')
|
||||
expect(wikilinks[1].textContent).toBe('Note B')
|
||||
})
|
||||
|
||||
it('handles pipe syntax [[target|display]]', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[path/to/note|My Display]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.textContent).toBe('My Display')
|
||||
expect(wikilink.getAttribute('data-wikilink-target')).toBe('path/to/note')
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('path/to/note')
|
||||
})
|
||||
|
||||
it('does not render wikilinks inside inline code', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Use `[[Not a link]]` syntax" onWikilinkClick={onClick} />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render wikilinks inside code blocks', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content={'```\n[[Not a link]]\n```'} onWikilinkClick={onClick} />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles notes with special characters in title', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Check [[Meeting — 2024/01/15]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.textContent).toBe('Meeting — 2024/01/15')
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('Meeting — 2024/01/15')
|
||||
})
|
||||
|
||||
it('does not transform wikilinks when onWikilinkClick is not provided', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Some Note]]" />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
expect(container.textContent).toContain('[[Some Note]]')
|
||||
})
|
||||
|
||||
it('renders wikilinks inside list items', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content={'- First [[Note A]]\n- Second [[Note B]]'} onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('has role="link" and tabIndex for accessibility', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Accessible Note]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.getAttribute('role')).toBe('link')
|
||||
expect(wikilink.getAttribute('tabindex')).toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,63 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import { memo, useMemo, useCallback, type MouseEvent } from 'react'
|
||||
import Markdown, { defaultUrlTransform } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import { preprocessWikilinks, WIKILINK_SCHEME } from '../utils/chatWikilinks'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
const REHYPE_PLUGINS = [rehypeHighlight]
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) {
|
||||
const rendered = useMemo(() => (
|
||||
<div className="ai-markdown">
|
||||
<Markdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>
|
||||
{content}
|
||||
function wikilinkUrlTransform(url: string): string {
|
||||
if (url.startsWith(WIKILINK_SCHEME)) return url
|
||||
return defaultUrlTransform(url)
|
||||
}
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
onWikilinkClick?: (target: string) => void
|
||||
}
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content, onWikilinkClick }: MarkdownContentProps) {
|
||||
const processedContent = useMemo(
|
||||
() => onWikilinkClick ? preprocessWikilinks(content) : content,
|
||||
[content, onWikilinkClick],
|
||||
)
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent) => {
|
||||
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-wikilink-target]')
|
||||
if (el) {
|
||||
e.preventDefault()
|
||||
onWikilinkClick?.(el.dataset.wikilinkTarget!)
|
||||
}
|
||||
}, [onWikilinkClick])
|
||||
|
||||
const components = useMemo(() => {
|
||||
if (!onWikilinkClick) return undefined
|
||||
return {
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
|
||||
if (href?.startsWith(WIKILINK_SCHEME)) {
|
||||
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
|
||||
return (
|
||||
<span className="chat-wikilink" data-wikilink-target={target} role="link" tabIndex={0}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <a href={href}>{children}</a>
|
||||
},
|
||||
}
|
||||
}, [onWikilinkClick])
|
||||
|
||||
return (
|
||||
<div className="ai-markdown" onClick={onWikilinkClick ? handleClick : undefined} role="presentation">
|
||||
<Markdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={components}
|
||||
urlTransform={onWikilinkClick ? wikilinkUrlTransform : undefined}
|
||||
>
|
||||
{processedContent}
|
||||
</Markdown>
|
||||
</div>
|
||||
), [content])
|
||||
return rendered
|
||||
)
|
||||
})
|
||||
|
||||
@@ -233,10 +233,10 @@ const mockEntries: VaultEntry[] = [
|
||||
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders top nav items (All Notes and Favorites)', () => {
|
||||
it('renders top nav items (All Notes)', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders section group headers only for types present in entries', () => {
|
||||
@@ -764,7 +764,7 @@ describe('Sidebar', () => {
|
||||
]
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a "Customize sections" button', () => {
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
|
||||
FileText, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -356,9 +356,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
{/* Top nav */}
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Star} label="Favorites" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'favorites' })} onClick={() => onSelect({ kind: 'filter', filter: 'favorites' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={TagSimple} label="Untagged" disabled />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
{modifiedCount > 0 && (
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
130
src/hooks/useAIChat.test.ts
Normal file
130
src/hooks/useAIChat.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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')
|
||||
})
|
||||
|
||||
it('does not pass session_id to avoid --resume (history is in prompt)', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// First message
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message — session_id should still be undefined (no --resume)
|
||||
act(() => { result.current.sendMessage('follow up') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
// Third argument is sessionId — must be undefined for both calls
|
||||
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
|
||||
expect(streamClaudeChatMock.mock.calls[1][2]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
@@ -17,7 +18,6 @@ export function useAIChat(
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
const sessionIdRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
if (!text.trim() || isStreaming) return
|
||||
@@ -29,11 +29,14 @@ 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, {
|
||||
onInit: (sid) => { sessionIdRef.current = sid },
|
||||
|
||||
// No session_id: each call is independent. Context is provided via
|
||||
// formatted history in the prompt, avoiding --resume which causes
|
||||
// double-context confusion when combined with in-prompt history.
|
||||
streamClaudeChat(messageWithHistory, systemPrompt || undefined, undefined, {
|
||||
onText: (chunk) => {
|
||||
if (abortRef.current) return
|
||||
accumulated += chunk
|
||||
@@ -55,17 +58,14 @@ export function useAIChat(
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
}).then(sid => {
|
||||
if (sid) sessionIdRef.current = sid
|
||||
})
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
}, [isStreaming, allContent, contextNotes, messages])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
setMessages([])
|
||||
setIsStreaming(false)
|
||||
setStreamingContent('')
|
||||
sessionIdRef.current = undefined
|
||||
}, [])
|
||||
|
||||
const retryMessage = useCallback((msgIndex: number) => {
|
||||
|
||||
@@ -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. */
|
||||
@@ -86,7 +87,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
|
||||
const { onSelect } = config
|
||||
|
||||
const selectFilter = useCallback((filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse') => {
|
||||
const selectFilter = useCallback((filter: 'all' | 'archived' | 'trash' | 'changes' | 'pulse') => {
|
||||
onSelect({ kind: 'filter', filter })
|
||||
}, [onSelect])
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -214,7 +216,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
// Navigation
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
|
||||
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
|
||||
{ id: 'go-favorites', label: 'Go to Favorites', group: 'Navigation', keywords: ['starred'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'favorites' }) },
|
||||
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
|
||||
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
@@ -258,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),
|
||||
@@ -276,5 +278,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onReindexVault,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -50,10 +50,13 @@ describe('useEntryActions', () => {
|
||||
handleDeleteProperty,
|
||||
setToastMessage,
|
||||
createTypeEntry,
|
||||
onFrontmatterPersisted,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const onFrontmatterPersisted = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -73,6 +76,7 @@ describe('useEntryActions', () => {
|
||||
trashedAt: expect.any(Number),
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note moved to trash')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,6 +95,7 @@ describe('useEntryActions', () => {
|
||||
trashedAt: null,
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note restored from trash')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +110,7 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', true)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: true })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note archived')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,6 +125,7 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', false)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: false })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note unarchived')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ interface EntryActionsConfig {
|
||||
handleDeleteProperty: (path: string, key: string) => Promise<void>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
createTypeEntry: (typeName: string) => Promise<VaultEntry>
|
||||
onFrontmatterPersisted?: () => void
|
||||
}
|
||||
|
||||
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
@@ -15,7 +16,7 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
|
||||
}
|
||||
|
||||
export function useEntryActions({
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry,
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted,
|
||||
}: EntryActionsConfig) {
|
||||
const handleTrashNote = useCallback(async (path: string) => {
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
@@ -23,26 +24,30 @@ export function useEntryActions({
|
||||
await handleUpdateFrontmatter(path, 'Trashed at', now)
|
||||
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
|
||||
setToastMessage('Note moved to trash')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleRestoreNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'Trashed', false)
|
||||
await handleDeleteProperty(path, 'Trashed at')
|
||||
updateEntry(path, { trashed: false, trashedAt: null })
|
||||
setToastMessage('Note restored from trash')
|
||||
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleArchiveNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'archived', true)
|
||||
updateEntry(path, { archived: true })
|
||||
setToastMessage('Note archived')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleUnarchiveNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'archived', false)
|
||||
updateEntry(path, { archived: false })
|
||||
setToastMessage('Note unarchived')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -226,12 +227,6 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onSelectFilter).toHaveBeenCalledWith('all')
|
||||
})
|
||||
|
||||
it('go-favorites selects favorites filter', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('go-favorites', h)
|
||||
expect(h.onSelectFilter).toHaveBeenCalledWith('favorites')
|
||||
})
|
||||
|
||||
it('go-archived selects archived filter', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('go-archived', h)
|
||||
@@ -305,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()
|
||||
|
||||
@@ -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
|
||||
@@ -67,7 +68,6 @@ const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
|
||||
|
||||
const FILTER_MAP: Record<string, SidebarFilter> = {
|
||||
'go-all-notes': 'all',
|
||||
'go-favorites': 'favorites',
|
||||
'go-archived': 'archived',
|
||||
'go-trash': 'trash',
|
||||
'go-changes': 'changes',
|
||||
@@ -78,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',
|
||||
@@ -97,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 {
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
trackMockChange: vi.fn(),
|
||||
mockInvoke: vi.fn().mockResolvedValue(''),
|
||||
}))
|
||||
vi.mock('./mockFrontmatterHelpers', () => ({
|
||||
@@ -289,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) => {
|
||||
@@ -320,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) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
|
||||
import { isTauri, mockInvoke, addMockEntry, updateMockContent, trackMockChange } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
@@ -111,12 +111,14 @@ async function invokeFrontmatter(command: string, args: Record<string, unknown>)
|
||||
function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string {
|
||||
const content = updateMockFrontmatter(path, key, value)
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
return content
|
||||
}
|
||||
|
||||
function applyMockFrontmatterDelete(path: string, key: string): string {
|
||||
const content = deleteMockFrontmatterProperty(path, key)
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -148,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). */
|
||||
@@ -168,6 +170,7 @@ export function frontmatterToEntryPatch(
|
||||
template: { template: str },
|
||||
sort: { sort: str },
|
||||
view: { view: str },
|
||||
visible: { visible: value === false ? false : null },
|
||||
}
|
||||
return updates[k] ?? {}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
}
|
||||
.ai-markdown a:hover { text-decoration: underline; }
|
||||
|
||||
.ai-markdown .chat-wikilink {
|
||||
display: inline;
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.ai-markdown .chat-wikilink:hover {
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ai-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
@@ -5,18 +5,19 @@
|
||||
*/
|
||||
|
||||
import { MOCK_CONTENT } from './mock-content'
|
||||
import { mockHandlers, addMockEntry, updateMockContent } from './mock-handlers'
|
||||
import { mockHandlers, addMockEntry, updateMockContent, trackMockChange } from './mock-handlers'
|
||||
import { tryVaultApi } from './vault-api'
|
||||
|
||||
export { addMockEntry, updateMockContent }
|
||||
export { addMockEntry, updateMockContent, trackMockChange }
|
||||
|
||||
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> {
|
||||
|
||||
@@ -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],
|
||||
@@ -347,3 +347,7 @@ export function updateMockContent(path: string, content: string): void {
|
||||
MOCK_CONTENT[path] = content
|
||||
syncWindowContent()
|
||||
}
|
||||
|
||||
export function trackMockChange(path: string): void {
|
||||
mockSavedSinceCommit.add(path)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -170,7 +175,7 @@ export interface PulseCommit {
|
||||
deleted: number
|
||||
}
|
||||
|
||||
export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
|
||||
export type SidebarSelection =
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
|
||||
@@ -23,6 +23,12 @@ describe('buildAgentSystemPrompt', () => {
|
||||
expect(prompt).toContain('Vault context:')
|
||||
expect(prompt).toContain('Recent notes: foo, bar')
|
||||
})
|
||||
|
||||
it('instructs AI to use wikilink syntax', () => {
|
||||
const prompt = buildAgentSystemPrompt()
|
||||
expect(prompt).toContain('[[')
|
||||
expect(prompt).toMatch(/wikilink/i)
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamClaudeAgent ---
|
||||
@@ -44,7 +50,7 @@ describe('streamClaudeAgent', () => {
|
||||
// Wait for the setTimeout mock response
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Build Laputa App'))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(onToolStart).not.toHaveBeenCalled()
|
||||
|
||||
@@ -17,6 +17,7 @@ You have full shell access. Use bash for file operations, search, bulk edits.
|
||||
Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note).
|
||||
|
||||
When you create or edit a note, call open_note(path) so the user sees it in Laputa.
|
||||
When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.
|
||||
Be concise and helpful. When you've completed a task, briefly summarize what you did.`
|
||||
|
||||
export function buildAgentSystemPrompt(vaultContext?: string): string {
|
||||
@@ -57,7 +58,7 @@ export async function streamClaudeAgent(
|
||||
): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText('AI Agent requires the Claude CLI. Install it and run the native app.')
|
||||
callbacks.onText('This note is related to [[Build Laputa App]] and [[Matteo Cellini]]. The AI Agent requires the Claude CLI for full functionality.')
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -50,6 +52,14 @@ describe('buildSystemPrompt', () => {
|
||||
expect(result.prompt).toContain('Hello world')
|
||||
expect(result.totalTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('instructs AI to use wikilink syntax', () => {
|
||||
const notes = [makeEntry('/test.md', 'Test Note')]
|
||||
const content = { '/test.md': 'content' }
|
||||
const result = buildSystemPrompt(notes, content)
|
||||
expect(result.prompt).toContain('[[')
|
||||
expect(result.prompt).toMatch(/wikilink/i)
|
||||
})
|
||||
})
|
||||
|
||||
// --- nextMessageId ---
|
||||
@@ -73,6 +83,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', () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ export function buildSystemPrompt(
|
||||
const preamble = [
|
||||
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
|
||||
'The user has selected the following notes as context. Use them to answer questions accurately.',
|
||||
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
@@ -76,6 +77,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 {
|
||||
|
||||
35
src/utils/chatWikilinks.ts
Normal file
35
src/utils/chatWikilinks.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const WIKILINK_SCHEME = 'wikilink://'
|
||||
|
||||
export { WIKILINK_SCHEME }
|
||||
|
||||
function encodeTarget(target: string): string {
|
||||
return encodeURIComponent(target).replace(/\(/g, '%28').replace(/\)/g, '%29')
|
||||
}
|
||||
|
||||
function escapeLinkText(text: string): string {
|
||||
return text.replace(/[[\]]/g, '\\$&')
|
||||
}
|
||||
|
||||
function replaceWikilinksInText(text: string): string {
|
||||
return text.replace(/\[\[([^\]]+)\]\]/g, (_, inner: string) => {
|
||||
const pipeIdx = inner.indexOf('|')
|
||||
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
|
||||
const display = pipeIdx !== -1 ? inner.slice(pipeIdx + 1) : inner
|
||||
return `[${escapeLinkText(display)}](${WIKILINK_SCHEME}${encodeTarget(target)})`
|
||||
})
|
||||
}
|
||||
|
||||
/** Convert [[Target]] to markdown links, but skip code blocks and inline code. */
|
||||
export function preprocessWikilinks(md: string): string {
|
||||
const segments: string[] = []
|
||||
const codeRegex = /(```[\s\S]*?```|`[^`\n]+`)/g
|
||||
let lastIndex = 0
|
||||
let match
|
||||
while ((match = codeRegex.exec(md)) !== null) {
|
||||
segments.push(replaceWikilinksInText(md.slice(lastIndex, match.index)))
|
||||
segments.push(match[0])
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
segments.push(replaceWikilinksInText(md.slice(lastIndex)))
|
||||
return segments.join('')
|
||||
}
|
||||
10
src/utils/indexingHelpers.ts
Normal file
10
src/utils/indexingHelpers.ts
Normal 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`
|
||||
}
|
||||
46
tests/smoke/ai-chat-wikilink-clickable.spec.ts
Normal file
46
tests/smoke/ai-chat-wikilink-clickable.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
test.describe('AI chat wikilink rendering', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
|
||||
// Select a note first so the AI panel has context
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open AI Chat with Ctrl+I
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Send a message to get a mock response containing wikilinks
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
await input.fill('Tell me about this note')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
// Wait for mock response (contains [[Build Laputa App]] and [[Matteo Cellini]])
|
||||
const wikilink = page.locator('.chat-wikilink').first()
|
||||
await expect(wikilink).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Verify wikilink text and attributes
|
||||
await expect(wikilink).toHaveText('Build Laputa App')
|
||||
await expect(wikilink).toHaveAttribute('data-wikilink-target', 'Build Laputa App')
|
||||
await expect(wikilink).toHaveAttribute('role', 'link')
|
||||
|
||||
// Verify second wikilink
|
||||
const secondWikilink = page.locator('.chat-wikilink').nth(1)
|
||||
await expect(secondWikilink).toHaveText('Matteo Cellini')
|
||||
|
||||
// Verify multiple wikilinks rendered
|
||||
const allWikilinks = page.locator('.chat-wikilink')
|
||||
await expect(allWikilinks).toHaveCount(2)
|
||||
|
||||
// Verify wikilink has pointer cursor (is styled as clickable)
|
||||
const cursor = await wikilink.evaluate(el => getComputedStyle(el).cursor)
|
||||
expect(cursor).toBe('pointer')
|
||||
})
|
||||
})
|
||||
71
tests/smoke/better-sync-error-ux.spec.ts
Normal file
71
tests/smoke/better-sync-error-ux.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
28
tests/smoke/indexing-reindex-status.spec.ts
Normal file
28
tests/smoke/indexing-reindex-status.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
39
tests/smoke/trashed-archived-not-saved.spec.ts
Normal file
39
tests/smoke/trashed-archived-not-saved.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Trash/archive notes appear in Changes', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('trashing a note increments the Changes badge', async ({ page }) => {
|
||||
const sidebar = page.locator('.app__sidebar')
|
||||
|
||||
// Wait for Changes nav item (mock starts with 3 modified files)
|
||||
const changesRow = sidebar.locator('div', { hasText: /^Changes/ }).first()
|
||||
await changesRow.waitFor({ timeout: 5000 })
|
||||
|
||||
// Read the initial badge count from the Changes row
|
||||
const badge = changesRow.locator('span').last()
|
||||
const initialCount = Number(await badge.textContent())
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
// Click the first note in the list to select it
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await noteListContainer.waitFor({ timeout: 5000 })
|
||||
// Click on the first visible note item (border-b items inside the list)
|
||||
const firstNote = noteListContainer.locator('.cursor-pointer').first()
|
||||
await firstNote.click()
|
||||
|
||||
// Trash the active note via command palette
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Trash Note')
|
||||
|
||||
// Wait for the badge count to increase
|
||||
await expect(async () => {
|
||||
const text = await badge.textContent()
|
||||
expect(Number(text)).toBeGreaterThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user