diff --git a/design/auto-pull-vault.pen b/design/auto-pull-vault.pen new file mode 100644 index 00000000..747b5ab3 --- /dev/null +++ b/design/auto-pull-vault.pen @@ -0,0 +1 @@ +{"children":[],"variables":{}} diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 3bb67985..92ffec08 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -1,4 +1,4 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::path::Path; use std::process::Command; @@ -290,6 +290,129 @@ pub fn git_commit(vault_path: &str, message: &str) -> Result { Ok(String::from_utf8_lossy(&commit.stdout).to_string()) } +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct GitPullResult { + pub status: String, // "up_to_date" | "updated" | "conflict" | "no_remote" | "error" + pub message: String, + #[serde(rename = "updatedFiles")] + pub updated_files: Vec, + #[serde(rename = "conflictFiles")] + pub conflict_files: Vec, +} + +/// Check whether the vault repo has at least one remote configured. +pub fn has_remote(vault_path: &str) -> Result { + let vault = Path::new(vault_path); + let output = Command::new("git") + .args(["remote"]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git remote: {}", e))?; + + Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()) +} + +/// Pull latest changes from remote. Uses --no-rebase to merge. +/// Returns a structured result with status and affected files. +pub fn git_pull(vault_path: &str) -> Result { + let vault = Path::new(vault_path); + + if !has_remote(vault_path)? { + return Ok(GitPullResult { + status: "no_remote".to_string(), + message: "No remote configured".to_string(), + updated_files: vec![], + conflict_files: vec![], + }); + } + + let output = Command::new("git") + .args(["pull", "--no-rebase"]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git pull: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.success() { + if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") { + return Ok(GitPullResult { + status: "up_to_date".to_string(), + message: "Already up to date".to_string(), + updated_files: vec![], + conflict_files: vec![], + }); + } + let updated = parse_updated_files(&stdout); + return Ok(GitPullResult { + status: "updated".to_string(), + message: format!("{} file(s) updated", updated.len()), + updated_files: updated, + conflict_files: vec![], + }); + } + + // Check for merge conflicts + let conflicts = get_conflict_files(vault_path).unwrap_or_default(); + if !conflicts.is_empty() { + return Ok(GitPullResult { + status: "conflict".to_string(), + message: format!("Merge conflict in {} file(s)", conflicts.len()), + updated_files: vec![], + conflict_files: conflicts, + }); + } + + // Network error or other failure — report as error + let detail = if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + stderr.trim().to_string() + }; + Ok(GitPullResult { + status: "error".to_string(), + message: detail, + updated_files: vec![], + conflict_files: vec![], + }) +} + +/// List files with merge conflicts (unmerged paths). +pub fn get_conflict_files(vault_path: &str) -> Result, String> { + let vault = Path::new(vault_path); + let output = Command::new("git") + .args(["diff", "--name-only", "--diff-filter=U"]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to check conflicts: {}", e))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect()) +} + +/// Parse `git pull` output to extract updated file paths. +fn parse_updated_files(stdout: &str) -> Vec { + stdout + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + // Lines like " path/to/file.md | 5 ++-" in diffstat + if trimmed.contains('|') { + let path = trimmed.split('|').next()?.trim(); + if !path.is_empty() { + return Some(path.to_string()); + } + } + None + }) + .collect() +} + /// Push to remote. pub fn git_push(vault_path: &str) -> Result { let vault = Path::new(vault_path); @@ -606,4 +729,177 @@ mod tests { "Error should mention 'nothing to commit'" ); } + + #[test] + fn test_has_remote_returns_false_for_local_repo() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + // A fresh local repo has no remote + assert!(!has_remote(vp).unwrap()); + } + + #[test] + fn test_has_remote_returns_true_when_remote_exists() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + Command::new("git") + .args(["remote", "add", "origin", "https://example.com/repo.git"]) + .current_dir(vault) + .output() + .unwrap(); + + assert!(has_remote(vp).unwrap()); + } + + #[test] + fn test_git_pull_no_remote_returns_no_remote() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + fs::write(vault.join("note.md"), "# Note\n").unwrap(); + git_commit(vp, "initial").unwrap(); + + let result = git_pull(vp).unwrap(); + assert_eq!(result.status, "no_remote"); + assert!(result.updated_files.is_empty()); + assert!(result.conflict_files.is_empty()); + } + + /// Set up a bare "remote" and a clone that acts as the working vault. + fn setup_remote_pair() -> (TempDir, TempDir, TempDir) { + let bare_dir = TempDir::new().unwrap(); + let bare = bare_dir.path(); + + Command::new("git") + .args(["init", "--bare"]) + .current_dir(bare) + .output() + .unwrap(); + + let clone_a_dir = TempDir::new().unwrap(); + Command::new("git") + .args(["clone", bare.to_str().unwrap(), "."]) + .current_dir(clone_a_dir.path()) + .output() + .unwrap(); + for cmd in &[ + &["config", "user.email", "a@test.com"][..], + &["config", "user.name", "User A"][..], + ] { + Command::new("git") + .args(*cmd) + .current_dir(clone_a_dir.path()) + .output() + .unwrap(); + } + + let clone_b_dir = TempDir::new().unwrap(); + Command::new("git") + .args(["clone", bare.to_str().unwrap(), "."]) + .current_dir(clone_b_dir.path()) + .output() + .unwrap(); + for cmd in &[ + &["config", "user.email", "b@test.com"][..], + &["config", "user.name", "User B"][..], + ] { + Command::new("git") + .args(*cmd) + .current_dir(clone_b_dir.path()) + .output() + .unwrap(); + } + + (bare_dir, clone_a_dir, clone_b_dir) + } + + #[test] + fn test_git_pull_up_to_date() { + let (_bare, clone_a, _clone_b) = setup_remote_pair(); + let vp_a = clone_a.path().to_str().unwrap(); + + // Push a commit from A + fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap(); + git_commit(vp_a, "initial").unwrap(); + git_push(vp_a).unwrap(); + + // Pulling again from A should be up to date + let result = git_pull(vp_a).unwrap(); + assert_eq!(result.status, "up_to_date"); + } + + #[test] + fn test_git_pull_updated_files() { + 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(); + + // A pushes a commit + fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap(); + git_commit(vp_a, "initial").unwrap(); + git_push(vp_a).unwrap(); + + // B pulls to get initial + git_pull(vp_b).unwrap(); + + // A makes a change and pushes + fs::write(clone_a.path().join("note.md"), "# Updated Note\n").unwrap(); + git_commit(vp_a, "update note").unwrap(); + git_push(vp_a).unwrap(); + + // B pulls and should see the update + let result = git_pull(vp_b).unwrap(); + assert_eq!(result.status, "updated"); + assert!(result.conflict_files.is_empty()); + } + + #[test] + fn test_get_conflict_files_empty_when_clean() { + let dir = setup_git_repo(); + let vault = dir.path(); + let vp = vault.to_str().unwrap(); + + fs::write(vault.join("note.md"), "# Note\n").unwrap(); + git_commit(vp, "initial").unwrap(); + + let conflicts = get_conflict_files(vp).unwrap(); + assert!(conflicts.is_empty()); + } + + #[test] + fn test_parse_updated_files_diffstat() { + let stdout = + " Fast-forward\n note.md | 2 +-\n project/plan.md | 4 ++--\n 2 files changed\n"; + let files = parse_updated_files(stdout); + assert_eq!(files, vec!["note.md", "project/plan.md"]); + } + + #[test] + fn test_parse_updated_files_empty() { + let stdout = "Already up to date.\n"; + let files = parse_updated_files(stdout); + assert!(files.is_empty()); + } + + #[test] + fn test_git_pull_result_serialization() { + let result = GitPullResult { + status: "updated".to_string(), + message: "2 file(s) updated".to_string(), + updated_files: vec!["note.md".to_string()], + conflict_files: vec![], + }; + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("\"updatedFiles\"")); + assert!(json.contains("\"conflictFiles\"")); + + let parsed: GitPullResult = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.status, "updated"); + assert_eq!(parsed.updated_files.len(), 1); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5ae3f114..93b1976d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,7 +11,7 @@ use std::path::Path; use ai_chat::{AiChatRequest, AiChatResponse}; use frontmatter::FrontmatterValue; -use git::{GitCommit, ModifiedFile}; +use git::{GitCommit, GitPullResult, ModifiedFile}; use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo}; use search::SearchResponse; use settings::Settings; @@ -75,6 +75,11 @@ fn git_commit(vault_path: String, message: String) -> Result { git::git_commit(&vault_path, &message) } +#[tauri::command] +fn git_pull(vault_path: String) -> Result { + git::git_pull(&vault_path) +} + #[tauri::command] fn git_push(vault_path: String) -> Result { git::git_push(&vault_path) @@ -243,6 +248,7 @@ pub fn run() { get_file_diff, get_file_diff_at_commit, git_commit, + git_pull, git_push, ai_chat, save_image, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 76727a8c..f452a3e5 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -9,6 +9,7 @@ pub struct Settings { pub google_key: Option, pub github_token: Option, pub github_username: Option, + pub auto_pull_interval_minutes: Option, } fn settings_path() -> Result { @@ -54,6 +55,7 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> { .github_username .map(|k| k.trim().to_string()) .filter(|k| !k.is_empty()), + auto_pull_interval_minutes: settings.auto_pull_interval_minutes, }; let json = serde_json::to_string_pretty(&cleaned) @@ -84,19 +86,12 @@ mod tests { #[test] fn test_default_settings_all_none() { let s = Settings::default(); - assert_eq!( - format!("{:?}", s), - format!( - "{:?}", - Settings { - anthropic_key: None, - openai_key: None, - google_key: None, - github_token: None, - github_username: None - } - ) - ); + assert!(s.anthropic_key.is_none()); + assert!(s.openai_key.is_none()); + assert!(s.google_key.is_none()); + assert!(s.github_token.is_none()); + assert!(s.github_username.is_none()); + assert!(s.auto_pull_interval_minutes.is_none()); } #[test] @@ -107,6 +102,7 @@ mod tests { google_key: Some("AIza-test".to_string()), github_token: Some("gho_xyz789".to_string()), github_username: Some("lucaong".to_string()), + ..Default::default() }; let json = serde_json::to_string(&settings).unwrap(); let parsed: Settings = serde_json::from_str(&json).unwrap(); @@ -132,11 +128,13 @@ mod tests { google_key: None, github_token: Some("gho_token123".to_string()), github_username: Some("lucaong".to_string()), + auto_pull_interval_minutes: Some(10), }); assert_eq!(loaded.anthropic_key.as_deref(), Some("sk-ant-key")); assert_eq!(loaded.openai_key.as_deref(), Some("sk-openai")); assert_eq!(loaded.github_token.as_deref(), Some("gho_token123")); assert_eq!(loaded.github_username.as_deref(), Some("lucaong")); + assert_eq!(loaded.auto_pull_interval_minutes, Some(10)); } #[test] diff --git a/src/App.test.tsx b/src/App.test.tsx index 33854bba..6b805d74 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -67,7 +67,8 @@ vi.mock('./mock-tauri', () => ({ if (cmd === 'get_modified_files') return [] if (cmd === 'get_note_content') return mockAllContent['/vault/project/test.md'] || '' if (cmd === 'get_file_history') return [] - if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null } + if (cmd === 'get_settings') return { anthropic_key: null, openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null } + if (cmd === 'git_pull') return { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] } if (cmd === 'save_settings') return null return null }), diff --git a/src/App.tsx b/src/App.tsx index 48dd6b66..752cb547 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { useVaultSwitcher } from './hooks/useVaultSwitcher' import { useGitHistory } from './hooks/useGitHistory' import { useUpdater } from './hooks/useUpdater' import { useNavigationHistory } from './hooks/useNavigationHistory' +import { useAutoSync } from './hooks/useAutoSync' import { UpdateBanner } from './components/UpdateBanner' import { setApiKey } from './utils/ai-chat' import { extractOutgoingLinks } from './utils/wikilinks' @@ -98,6 +99,17 @@ function App() { useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key]) + const autoSync = useAutoSync({ + vaultPath: vaultSwitcher.vaultPath, + intervalMinutes: settings.auto_pull_interval_minutes, + onVaultUpdated: vault.reloadVault, + onConflict: (files) => { + const names = files.map((f) => f.split('/').pop()).join(', ') + setToastMessage(`Conflict in ${names} — review needed`) + }, + onToast: (msg) => setToastMessage(msg), + }) + const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry }) const navHistory = useNavigationHistory() @@ -280,7 +292,7 @@ function App() { /> - setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} /> + setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} onTriggerSync={autoSync.triggerSync} /> setToastMessage(null)} /> diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 71aac1a7..036174a2 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -19,6 +19,7 @@ const emptySettings: Settings = { google_key: null, github_token: null, github_username: null, + auto_pull_interval_minutes: null, } const populatedSettings: Settings = { @@ -27,6 +28,7 @@ const populatedSettings: Settings = { google_key: null, github_token: null, github_username: null, + auto_pull_interval_minutes: 5, } describe('SettingsPanel', () => { @@ -90,6 +92,7 @@ describe('SettingsPanel', () => { google_key: null, github_token: null, github_username: null, + auto_pull_interval_minutes: 5, }) expect(onClose).toHaveBeenCalled() }) @@ -110,6 +113,7 @@ describe('SettingsPanel', () => { google_key: null, github_token: null, github_username: null, + auto_pull_interval_minutes: 5, }) }) @@ -151,6 +155,7 @@ describe('SettingsPanel', () => { google_key: null, github_token: null, github_username: null, + auto_pull_interval_minutes: 5, }) }) diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 70409701..e4bd67e9 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -290,6 +290,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit ({ anthropic_key: anthropicKey.trim() || null, @@ -297,7 +298,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit { onSave(buildSettings()) @@ -346,6 +348,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit @@ -378,6 +381,7 @@ interface SettingsBodyProps { githubToken: string | null; githubUsername: string | null onGitHubConnected: (token: string, username: string) => void onGitHubDisconnect: () => void + pullInterval: number; setPullInterval: (v: number) => void } function SettingsBody(props: SettingsBodyProps) { @@ -409,6 +413,33 @@ function SettingsBody(props: SettingsBodyProps) { onConnected={props.onGitHubConnected} onDisconnect={props.onGitHubDisconnect} /> + +
+ +
+
Sync
+
+ Automatically pull vault changes from Git in the background. +
+
+ +
+ + +
) } diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 0f94fd37..59b7c844 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react' -import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot } from 'lucide-react' +import { Package, GitBranch, RefreshCw, Sparkles, FileText, Bell, Settings, FolderOpen, Check, Github, CircleDot, AlertTriangle, Loader2 } from 'lucide-react' +import type { SyncStatus } from '../types' export interface VaultOption { label: string @@ -17,6 +18,10 @@ interface StatusBarProps { onConnectGitHub?: () => void onClickPending?: () => void hasGitHub?: boolean + syncStatus?: SyncStatus + lastSyncTime?: number | null + conflictCount?: number + onTriggerSync?: () => void } function VaultMenuItem({ vault, isActive, onSelect }: { vault: VaultOption; isActive: boolean; onSelect: () => void }) { @@ -105,7 +110,34 @@ const ICON_STYLE = { display: 'flex', alignItems: 'center', gap: 4 } as const const DISABLED_STYLE = { display: 'flex', alignItems: 'center', opacity: 0.4, cursor: 'not-allowed' } as const const SEP_STYLE = { color: 'var(--border)' } as const -export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub }: StatusBarProps) { +function formatSyncLabel(status: SyncStatus, lastSyncTime: number | null): string { + if (status === 'syncing') return 'Syncing…' + if (status === 'conflict') return 'Conflict' + if (status === 'error') return 'Sync failed' + if (!lastSyncTime) return 'Not synced' + const elapsed = Math.round((Date.now() - lastSyncTime) / 1000) + if (elapsed < 60) return 'Synced just now' + const mins = Math.floor(elapsed / 60) + return `Synced ${mins}m ago` +} + +function syncIconColor(status: SyncStatus): string { + if (status === 'conflict') return 'var(--accent-orange)' + if (status === 'error') return 'var(--muted-foreground)' + return 'var(--accent-green)' +} + +export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, onTriggerSync }: StatusBarProps) { + // Force re-render every 30s to keep relative time label fresh + const [, setTick] = useState(0) + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), 30_000) + return () => clearInterval(id) + }, []) + + const syncLabel = formatSyncLabel(syncStatus, lastSyncTime) + const SyncIcon = syncStatus === 'syncing' ? Loader2 : syncStatus === 'conflict' ? AlertTriangle : RefreshCw + return (