Compare commits
13 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fd3ea02ae | ||
|
|
8da1484ebf | ||
|
|
90ebc2e939 | ||
|
|
c14927df8f | ||
|
|
a6d60695a2 | ||
|
|
7ddc0c14bf | ||
|
|
2a00b8aac7 | ||
|
|
b7d2304282 | ||
|
|
50b5fa9c2e | ||
|
|
963e7cf111 | ||
|
|
c9a5d20c12 | ||
|
|
586e1fcde5 | ||
|
|
75d67623ce |
@@ -1 +1 @@
|
||||
33549
|
||||
66070
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
@@ -75,7 +74,6 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
@@ -249,9 +247,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 +263,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Go")
|
||||
.item(&all_notes)
|
||||
.item(&favorites)
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
@@ -454,7 +448,6 @@ mod tests {
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
34
src/App.tsx
34
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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,19 +232,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 +262,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 +332,7 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
})
|
||||
|
||||
const handleDeleteNote = useCallback(async (path: string) => {
|
||||
@@ -509,11 +527,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} />
|
||||
)}
|
||||
|
||||
@@ -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' })} />
|
||||
|
||||
114
src/hooks/useAIChat.test.ts
Normal file
114
src/hooks/useAIChat.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
|
||||
// Capture what streamClaudeChat receives
|
||||
const streamClaudeChatMock = vi.fn<
|
||||
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
|
||||
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
|
||||
>()
|
||||
|
||||
vi.mock('../utils/ai-chat', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
|
||||
return {
|
||||
...actual,
|
||||
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
||||
streamClaudeChatMock(...args)
|
||||
// Simulate async: call onDone after a tick
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onInit?.('test-session')
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('test-session')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { useAIChat } from './useAIChat'
|
||||
|
||||
beforeEach(() => {
|
||||
streamClaudeChatMock.mockClear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useAIChat', () => {
|
||||
const emptyContent: Record<string, string> = {}
|
||||
|
||||
it('sends first message without history', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
|
||||
const message = streamClaudeChatMock.mock.calls[0][0]
|
||||
// First message: no history, so just the plain text
|
||||
expect(message).toBe('hello')
|
||||
})
|
||||
|
||||
it('includes conversation history in second message', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('What is Rust?') })
|
||||
// Wait for mock response
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Now messages state has: [user: What is Rust?, assistant: mock response]
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Send second message
|
||||
act(() => { result.current.sendMessage('Tell me more') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
const secondMessage = streamClaudeChatMock.mock.calls[1][0]
|
||||
// Should contain conversation history
|
||||
expect(secondMessage).toContain('What is Rust?')
|
||||
expect(secondMessage).toContain('mock response')
|
||||
expect(secondMessage).toContain('Tell me more')
|
||||
})
|
||||
|
||||
it('resets history on clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send a message and get response
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear conversation
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Send new message — should have no history
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start')
|
||||
})
|
||||
|
||||
it('accumulates multiple exchanges in history', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Exchange 1
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 2
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 3
|
||||
act(() => { result.current.sendMessage('Q3') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
|
||||
const thirdMessage = streamClaudeChatMock.mock.calls[2][0]
|
||||
// Should contain all prior exchanges
|
||||
expect(thirdMessage).toContain('Q1')
|
||||
expect(thirdMessage).toContain('Q2')
|
||||
expect(thirdMessage).toContain('Q3')
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
export function useAIChat(
|
||||
@@ -29,9 +30,11 @@ export function useAIChat(
|
||||
abortRef.current = false
|
||||
|
||||
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
|
||||
const history = trimHistory(messages, MAX_HISTORY_TOKENS)
|
||||
const messageWithHistory = formatMessageWithHistory(history, text.trim())
|
||||
let accumulated = ''
|
||||
|
||||
streamClaudeChat(text.trim(), systemPrompt || undefined, sessionIdRef.current, {
|
||||
streamClaudeChat(messageWithHistory, systemPrompt || undefined, sessionIdRef.current, {
|
||||
onInit: (sid) => { sessionIdRef.current = sid },
|
||||
|
||||
onText: (chunk) => {
|
||||
@@ -58,7 +61,7 @@ export function useAIChat(
|
||||
}).then(sid => {
|
||||
if (sid) sessionIdRef.current = sid
|
||||
})
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
}, [isStreaming, allContent, contextNotes, messages])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
|
||||
@@ -86,7 +86,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])
|
||||
|
||||
|
||||
@@ -214,7 +214,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' }) },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -226,12 +226,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)
|
||||
|
||||
@@ -67,7 +67,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',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
|
||||
@@ -8,6 +8,8 @@ vi.mock('../mock-tauri', () => ({
|
||||
import {
|
||||
estimateTokens, buildSystemPrompt,
|
||||
nextMessageId, checkClaudeCli, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory,
|
||||
type ChatMessage, MAX_HISTORY_TOKENS,
|
||||
} from './ai-chat'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -73,6 +75,107 @@ describe('checkClaudeCli', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- trimHistory ---
|
||||
|
||||
describe('trimHistory', () => {
|
||||
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
|
||||
role, content, id: `msg-${content}`,
|
||||
})
|
||||
|
||||
it('returns empty array for empty history', () => {
|
||||
expect(trimHistory([], 1000)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all messages when under token limit', () => {
|
||||
const history = [msg('user', 'hi'), msg('assistant', 'hello')]
|
||||
expect(trimHistory(history, 1000)).toEqual(history)
|
||||
})
|
||||
|
||||
it('drops oldest messages when over token limit', () => {
|
||||
const history = [
|
||||
msg('user', 'a'.repeat(400)), // 100 tokens
|
||||
msg('assistant', 'b'.repeat(400)), // 100 tokens
|
||||
msg('user', 'c'.repeat(400)), // 100 tokens
|
||||
]
|
||||
const result = trimHistory(history, 200)
|
||||
// Should keep the two most recent messages (200 tokens)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].content).toBe('b'.repeat(400))
|
||||
expect(result[1].content).toBe('c'.repeat(400))
|
||||
})
|
||||
|
||||
it('keeps at least one message if it fits', () => {
|
||||
const history = [
|
||||
msg('user', 'a'.repeat(2000)), // 500 tokens
|
||||
msg('assistant', 'b'.repeat(80)), // 20 tokens
|
||||
]
|
||||
const result = trimHistory(history, 30)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toBe('b'.repeat(80))
|
||||
})
|
||||
|
||||
it('returns empty when single message exceeds limit', () => {
|
||||
const history = [msg('user', 'a'.repeat(4000))] // 1000 tokens
|
||||
expect(trimHistory(history, 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// --- formatMessageWithHistory ---
|
||||
|
||||
describe('formatMessageWithHistory', () => {
|
||||
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
|
||||
role, content, id: `msg-${content}`,
|
||||
})
|
||||
|
||||
it('returns bare message when no history', () => {
|
||||
expect(formatMessageWithHistory([], 'hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('includes conversation history before the new message', () => {
|
||||
const history = [msg('user', 'What is Rust?'), msg('assistant', 'A systems language.')]
|
||||
const result = formatMessageWithHistory(history, 'How does it compare to Go?')
|
||||
expect(result).toContain('What is Rust?')
|
||||
expect(result).toContain('A systems language.')
|
||||
expect(result).toContain('How does it compare to Go?')
|
||||
})
|
||||
|
||||
it('labels user and assistant messages correctly', () => {
|
||||
const history = [msg('user', 'Q1'), msg('assistant', 'A1')]
|
||||
const result = formatMessageWithHistory(history, 'Q2')
|
||||
expect(result).toContain('[user]: Q1')
|
||||
expect(result).toContain('[assistant]: A1')
|
||||
expect(result).toContain('[user]: Q2')
|
||||
})
|
||||
|
||||
it('preserves message order', () => {
|
||||
const history = [
|
||||
msg('user', 'first'),
|
||||
msg('assistant', 'second'),
|
||||
msg('user', 'third'),
|
||||
msg('assistant', 'fourth'),
|
||||
]
|
||||
const result = formatMessageWithHistory(history, 'fifth')
|
||||
const firstIdx = result.indexOf('first')
|
||||
const secondIdx = result.indexOf('second')
|
||||
const thirdIdx = result.indexOf('third')
|
||||
const fourthIdx = result.indexOf('fourth')
|
||||
const fifthIdx = result.indexOf('fifth')
|
||||
expect(firstIdx).toBeLessThan(secondIdx)
|
||||
expect(secondIdx).toBeLessThan(thirdIdx)
|
||||
expect(thirdIdx).toBeLessThan(fourthIdx)
|
||||
expect(fourthIdx).toBeLessThan(fifthIdx)
|
||||
})
|
||||
})
|
||||
|
||||
// --- MAX_HISTORY_TOKENS ---
|
||||
|
||||
describe('MAX_HISTORY_TOKENS', () => {
|
||||
it('is a reasonable token limit', () => {
|
||||
expect(MAX_HISTORY_TOKENS).toBeGreaterThan(10_000)
|
||||
expect(MAX_HISTORY_TOKENS).toBeLessThan(200_000)
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamClaudeChat ---
|
||||
|
||||
describe('streamClaudeChat', () => {
|
||||
|
||||
@@ -76,6 +76,34 @@ export function nextMessageId(): string {
|
||||
return `msg-${++msgIdCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
// --- Conversation history ---
|
||||
|
||||
/** Max tokens of history to include in each request. */
|
||||
export const MAX_HISTORY_TOKENS = 100_000
|
||||
|
||||
/** Keep the most recent messages that fit within `maxTokens`. Drops oldest first. */
|
||||
export function trimHistory(history: ChatMessage[], maxTokens: number): ChatMessage[] {
|
||||
let tokenCount = 0
|
||||
const result: ChatMessage[] = []
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const tokens = estimateTokens(history[i].content)
|
||||
if (tokenCount + tokens > maxTokens) break
|
||||
result.unshift(history[i])
|
||||
tokenCount += tokens
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Format conversation history + new message into a single prompt for the CLI. */
|
||||
export function formatMessageWithHistory(history: ChatMessage[], newMessage: string): string {
|
||||
if (history.length === 0) return newMessage
|
||||
|
||||
const lines = history.map(m => `[${m.role}]: ${m.content}`)
|
||||
lines.push(`[user]: ${newMessage}`)
|
||||
|
||||
return `<conversation_history>\n${lines.join('\n\n')}\n</conversation_history>\n\nContinue the conversation. Respond only to the latest [user] message.`
|
||||
}
|
||||
|
||||
// --- Claude CLI status ---
|
||||
|
||||
export interface ClaudeCliStatus {
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
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