feat: auto-pull vault changes from Git (background sync + conflict handling) (#79)

* feat: add auto-pull vault sync with conflict handling

- Add git_pull, has_remote, get_conflict_files to Rust backend
- Add GitPullResult type and auto_pull_interval_minutes to Settings
- Create useAutoSync hook (pull on launch, focus, periodic interval)
- Update StatusBar with real sync status indicator
- Add pull interval setting to SettingsPanel
- Add mock handler for git_pull
- Update existing tests for new Settings field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add tests for git pull, settings, and useAutoSync hook

- Add Rust tests: has_remote, git_pull (no_remote, up_to_date, updated),
  get_conflict_files, parse_updated_files, GitPullResult serialization
- Add frontend tests: useAutoSync (mount pull, focus pull, conflict,
  error, manual trigger, concurrent prevention, no_remote)
- Fix existing settings tests for new auto_pull_interval_minutes field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: add auto-pull-vault wireframes

Frames showing: sync idle, syncing, conflict indicator,
settings sync section, and conflict toast notification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add auto_pull_interval_minutes to all Settings literals

Fix build error and test fixtures missing the new field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: cargo fmt

* ci: re-trigger CI after flaky test

* ci: retrigger after disk space cleanup

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-26 01:55:16 +01:00
committed by GitHub
parent c85e343dd9
commit 369ecdc009
16 changed files with 719 additions and 23 deletions

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -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<String, String> {
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<String>,
#[serde(rename = "conflictFiles")]
pub conflict_files: Vec<String>,
}
/// Check whether the vault repo has at least one remote configured.
pub fn has_remote(vault_path: &str) -> Result<bool, String> {
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<GitPullResult, String> {
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<Vec<String>, 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<String> {
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<String, String> {
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);
}
}

View File

@@ -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<String, String> {
git::git_commit(&vault_path, &message)
}
#[tauri::command]
fn git_pull(vault_path: String) -> Result<GitPullResult, String> {
git::git_pull(&vault_path)
}
#[tauri::command]
fn git_push(vault_path: String) -> Result<String, String> {
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,

View File

@@ -9,6 +9,7 @@ pub struct Settings {
pub google_key: Option<String>,
pub github_token: Option<String>,
pub github_username: Option<String>,
pub auto_pull_interval_minutes: Option<u32>,
}
fn settings_path() -> Result<PathBuf, String> {
@@ -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]

View File

@@ -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
}),

View File

@@ -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() {
/>
</div>
</div>
<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} />
<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} onTriggerSync={autoSync.triggerSync} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />

View File

@@ -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,
})
})

View File

@@ -290,6 +290,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
const [googleKey, setGoogleKey] = useState(settings.google_key ?? '')
const [githubToken, setGithubToken] = useState(settings.github_token)
const [githubUsername, setGithubUsername] = useState(settings.github_username)
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
const buildSettings = useCallback((ghOverride?: { token: string | null; username: string | null }): Settings => ({
anthropic_key: anthropicKey.trim() || null,
@@ -297,7 +298,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
google_key: googleKey.trim() || null,
github_token: ghOverride ? ghOverride.token : (githubToken ?? null),
github_username: ghOverride ? ghOverride.username : (githubUsername ?? null),
}), [anthropicKey, openaiKey, googleKey, githubToken, githubUsername])
auto_pull_interval_minutes: pullInterval,
}), [anthropicKey, openaiKey, googleKey, githubToken, githubUsername, pullInterval])
const handleSave = () => {
onSave(buildSettings())
@@ -346,6 +348,7 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
googleKey={googleKey} setGoogleKey={setGoogleKey}
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
pullInterval={pullInterval} setPullInterval={setPullInterval}
/>
<SettingsFooter onClose={onClose} onSave={handleSave} />
</div>
@@ -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}
/>
<div style={{ height: 1, background: 'var(--border)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Sync</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
Automatically pull vault changes from Git in the background.
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Pull interval (minutes)</label>
<select
value={props.pullInterval}
onChange={(e) => props.setPullInterval(Number(e.target.value))}
className="border border-border bg-transparent text-foreground rounded"
style={{ fontSize: 13, padding: '8px 10px', outline: 'none', fontFamily: 'inherit' }}
data-testid="settings-pull-interval"
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={15}>15</option>
<option value={30}>30</option>
</select>
</div>
</div>
)
}

View File

@@ -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 (
<footer style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: 'var(--sidebar)', borderTop: '1px solid var(--border)', padding: '0 8px', fontSize: 11, color: 'var(--muted-foreground)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -115,7 +147,23 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><GitBranch size={13} />main</span>
<span style={SEP_STYLE}>|</span>
<span style={ICON_STYLE}><RefreshCw size={13} style={{ color: 'var(--accent-green)' }} />Synced 2m ago</span>
<span
role="button"
onClick={onTriggerSync}
style={{ ...ICON_STYLE, cursor: onTriggerSync ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3 }}
title={syncStatus === 'syncing' ? 'Syncing…' : 'Click to sync now'}
data-testid="status-sync"
>
<SyncIcon size={13} style={{ color: syncIconColor(syncStatus) }} className={syncStatus === 'syncing' ? 'animate-spin' : ''} />{syncLabel}
</span>
{conflictCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>
<span style={{ ...ICON_STYLE, color: 'var(--destructive, #e03e3e)' }} data-testid="status-conflict-count">
<AlertTriangle size={13} />{conflictCount} conflict{conflictCount > 1 ? 's' : ''}
</span>
</>
)}
{modifiedCount > 0 && (
<>
<span style={SEP_STYLE}>|</span>

View File

@@ -0,0 +1,176 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { useAutoSync } from './useAutoSync'
import type { GitPullResult } from '../types'
const mockInvokeFn = vi.fn()
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
}))
function upToDate(): GitPullResult {
return { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }
}
function updated(files: string[]): GitPullResult {
return { status: 'updated', message: `${files.length} file(s) updated`, updatedFiles: files, conflictFiles: [] }
}
function conflict(files: string[]): GitPullResult {
return { status: 'conflict', message: `Merge conflict in ${files.length} file(s)`, updatedFiles: [], conflictFiles: files }
}
describe('useAutoSync', () => {
const onVaultUpdated = vi.fn()
const onConflict = vi.fn()
const onToast = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockInvokeFn.mockResolvedValue(upToDate())
})
function renderSync(intervalMinutes: number | null = 5) {
return renderHook(() =>
useAutoSync({
vaultPath: '/Users/luca/Laputa',
intervalMinutes,
onVaultUpdated,
onConflict,
onToast,
}),
)
}
it('pulls on mount (app launch)', async () => {
renderSync()
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
})
})
it('sets syncStatus to idle after up_to_date pull', async () => {
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
expect(result.current.lastSyncTime).not.toBeNull()
})
})
it('calls onVaultUpdated and onToast when pull has updates', async () => {
mockInvokeFn.mockResolvedValue(updated(['note.md', 'project/plan.md']))
const { result } = renderSync()
await waitFor(() => {
expect(onVaultUpdated).toHaveBeenCalled()
expect(onToast).toHaveBeenCalledWith('Pulled 2 update(s) from remote')
expect(result.current.syncStatus).toBe('idle')
})
})
it('calls onConflict and sets conflict status when pull has conflicts', async () => {
mockInvokeFn.mockResolvedValue(conflict(['note.md']))
const { result } = renderSync()
await waitFor(() => {
expect(onConflict).toHaveBeenCalledWith(['note.md'])
expect(result.current.syncStatus).toBe('conflict')
expect(result.current.conflictFiles).toEqual(['note.md'])
})
})
it('sets error status when pull fails', async () => {
mockInvokeFn.mockRejectedValue(new Error('Network error'))
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('error')
})
})
it('pulls on window focus', async () => {
renderSync()
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
})
mockInvokeFn.mockClear()
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
})
})
it('triggerSync allows manual pull', async () => {
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
})
mockInvokeFn.mockClear()
mockInvokeFn.mockResolvedValue(updated(['note.md']))
await act(async () => {
result.current.triggerSync()
})
await waitFor(() => {
expect(mockInvokeFn).toHaveBeenCalledWith('git_pull', { vaultPath: '/Users/luca/Laputa' })
expect(onToast).toHaveBeenCalledWith('Pulled 1 update(s) from remote')
})
})
it('handles no_remote status silently', async () => {
mockInvokeFn.mockResolvedValue({
status: 'no_remote', message: 'No remote configured', updatedFiles: [], conflictFiles: [],
})
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('idle')
expect(onVaultUpdated).not.toHaveBeenCalled()
expect(onToast).not.toHaveBeenCalled()
})
})
it('does not fire concurrent pulls', async () => {
let resolveFirst: ((v: GitPullResult) => void) | null = null
mockInvokeFn.mockImplementation(() => new Promise<GitPullResult>((r) => { resolveFirst = r }))
const { result } = renderSync()
// First pull is in flight
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
// Trigger a manual sync while first is still running
act(() => {
result.current.triggerSync()
})
// Should NOT have fired a second call
expect(mockInvokeFn).toHaveBeenCalledTimes(1)
// Resolve the first
await act(async () => {
resolveFirst?.(upToDate())
})
})
it('handles error status from git_pull result', async () => {
mockInvokeFn.mockResolvedValue({
status: 'error', message: 'remote: Not Found', updatedFiles: [], conflictFiles: [],
})
const { result } = renderSync()
await waitFor(() => {
expect(result.current.syncStatus).toBe('error')
})
})
})

94
src/hooks/useAutoSync.ts Normal file
View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { GitPullResult, SyncStatus } from '../types'
const DEFAULT_INTERVAL_MS = 5 * 60_000
function tauriCall<T>(cmd: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(cmd, args) : mockInvoke<T>(cmd, args)
}
interface UseAutoSyncOptions {
vaultPath: string
intervalMinutes: number | null
onVaultUpdated: () => void
onConflict: (files: string[]) => void
onToast: (msg: string) => void
}
export interface AutoSyncState {
syncStatus: SyncStatus
lastSyncTime: number | null
conflictFiles: string[]
triggerSync: () => void
}
export function useAutoSync({
vaultPath,
intervalMinutes,
onVaultUpdated,
onConflict,
onToast,
}: UseAutoSyncOptions): AutoSyncState {
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle')
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null)
const [conflictFiles, setConflictFiles] = useState<string[]>([])
const syncingRef = useRef(false)
const callbacksRef = useRef({ onVaultUpdated, onConflict, onToast })
callbacksRef.current = { onVaultUpdated, onConflict, onToast }
const performPull = useCallback(async () => {
if (syncingRef.current) return
syncingRef.current = true
setSyncStatus('syncing')
try {
const result = await tauriCall<GitPullResult>('git_pull', { vaultPath })
setLastSyncTime(Date.now())
if (result.status === 'updated') {
setSyncStatus('idle')
setConflictFiles([])
callbacksRef.current.onVaultUpdated()
callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
} else if (result.status === 'conflict') {
setSyncStatus('conflict')
setConflictFiles(result.conflictFiles)
callbacksRef.current.onConflict(result.conflictFiles)
} else if (result.status === 'error') {
setSyncStatus('error')
} else {
// up_to_date or no_remote
setSyncStatus('idle')
setConflictFiles([])
}
} catch {
setSyncStatus('error')
setLastSyncTime(Date.now())
} finally {
syncingRef.current = false
}
}, [vaultPath])
// Pull on mount (app launch)
useEffect(() => {
performPull()
}, [performPull])
// Pull on window focus (app foreground)
useEffect(() => {
const handleFocus = () => { performPull() }
window.addEventListener('focus', handleFocus)
return () => window.removeEventListener('focus', handleFocus)
}, [performPull])
// Periodic pull
useEffect(() => {
const ms = (intervalMinutes ?? 5) * 60_000 || DEFAULT_INTERVAL_MS
const id = setInterval(performPull, ms)
return () => clearInterval(id)
}, [performPull, intervalMinutes])
return { syncStatus, lastSyncTime, conflictFiles, triggerSync: performPull }
}

View File

@@ -9,6 +9,7 @@ const defaultSettings: Settings = {
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: null,
}
const savedSettings: Settings = {
@@ -17,6 +18,7 @@ const savedSettings: Settings = {
google_key: 'AIza-test',
github_token: null,
github_username: null,
auto_pull_interval_minutes: null,
}
let mockSettingsStore: Settings = { ...defaultSettings }
@@ -77,6 +79,7 @@ describe('useSettings', () => {
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: null,
}
await act(async () => {

View File

@@ -13,6 +13,7 @@ const EMPTY_SETTINGS: Settings = {
google_key: null,
github_token: null,
github_username: null,
auto_pull_interval_minutes: null,
}
export function useSettings() {

View File

@@ -118,10 +118,21 @@ export function useVaultLoader(vaultPath: string) {
const commitAndPush = useCallback((message: string): Promise<string> =>
commitWithPush(vaultPath, message), [vaultPath])
const reloadVault = useCallback(async () => {
try {
const data = await loadVaultData(vaultPath)
setEntries(data.entries)
setAllContent((prev) => ({ ...prev, ...data.allContent }))
loadModifiedFiles()
} catch (err) {
console.warn('Vault reload failed:', err)
}
}, [vaultPath, loadModifiedFiles])
return {
entries, allContent, modifiedFiles,
addEntry, updateEntry, removeEntry, replaceEntry, updateContent,
loadModifiedFiles, loadGitHistory, loadDiff, loadDiffAtCommit,
getNoteStatus, commitAndPush,
getNoteStatus, commitAndPush, reloadVault,
}
}

View File

@@ -3,7 +3,7 @@
* Each handler simulates a Tauri backend command.
*/
import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser } from '../types'
import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult } from '../types'
import { MOCK_CONTENT } from './mock-content'
import { MOCK_ENTRIES } from './mock-entries'
@@ -79,6 +79,7 @@ let mockSettings: Settings = {
google_key: null,
github_token: 'gho_mock_token_for_testing',
github_username: 'lucaong',
auto_pull_interval_minutes: 5,
}
let mockDeviceFlowPollCount = 0
@@ -152,6 +153,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
mockSavedSinceCommit.clear()
return `[main abc1234] ${args.message}\n ${count} files changed`
},
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
git_push: () => 'Everything up-to-date',
ai_chat: handleAiChat,
save_note_content: (args: { path: string; content: string }) => {
@@ -173,6 +175,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
google_key: trimOrNull(s.google_key),
github_token: trimOrNull(s.github_token),
github_username: trimOrNull(s.github_username),
auto_pull_interval_minutes: s.auto_pull_interval_minutes ?? 5,
}
return null
},

View File

@@ -50,8 +50,18 @@ export interface Settings {
google_key: string | null
github_token: string | null
github_username: string | null
auto_pull_interval_minutes: number | null
}
export interface GitPullResult {
status: 'up_to_date' | 'updated' | 'conflict' | 'no_remote' | 'error'
message: string
updatedFiles: string[]
conflictFiles: string[]
}
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict'
export interface DeviceFlowStart {
device_code: string
user_code: string