diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index d9c1be6b..b83a0ef3 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -342,6 +342,10 @@ interface PulseCommit { - Configurable interval (from app settings: `auto_pull_interval_minutes`) - Pulls on interval, pushes after commits - Detects merge conflicts → opens `ConflictResolverModal` +- Tracks remote status (branch, ahead/behind via `git_remote_status`) +- Handles push rejection (divergence) → sets `pull_required` status +- `pullAndPush()`: pulls then auto-pushes for divergence recovery +- `ConflictNoteBanner`: inline banner in editor for conflicted notes (Keep mine / Keep theirs) ### Frontend Integration @@ -350,6 +354,9 @@ interface PulseCommit { - **Git history**: Shown in Inspector panel for active note - **Commit dialog**: Triggered from sidebar or Cmd+K - **Pulse view**: Activity feed when Pulse filter is selected +- **Pull command**: Cmd+K → "Pull from Remote", also in Vault menu +- **Git status popup**: Click sync badge → shows branch, ahead/behind, Pull button +- **Conflict banner**: Inline banner in editor with Keep mine / Keep theirs for conflicted notes ## BlockNote Customization diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 04ff744e..17e8e733 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -565,17 +565,34 @@ flowchart LR flowchart TD AS["useAutoSync\n(configurable interval)"] --> PULL["invoke('git_pull')"] PULL --> PC{Result?} - PC -->|Conflicts| CM["ConflictResolverModal"] + PC -->|Conflicts| CM["ConflictResolverModal\nor ConflictNoteBanner"] PC -->|Fast-forward| RV["reload vault"] PC -->|Up to date| DONE["idle"] - AS --> PUSH["invoke('git_push')"] - MAN["Manual commit\n(CommitDialog)"] --> GC["invoke('git_commit', message)"] GC --> GP["invoke('git_push')"] - GP --> RM["Reload modified files"] + GP --> PR{Push result?} + PR -->|ok| RM["Reload modified files"] + PR -->|rejected| DIV["syncStatus = pull_required"] + DIV -->|User clicks badge| PAP["pullAndPush()"] + PAP --> PULL2["invoke('git_pull')"] + PULL2 --> GP2["invoke('git_push')"] + GP2 --> RM + + CMD["Cmd+K → Pull\nor Menu → Pull"] --> PULL + STATUS["Click sync badge"] --> POPUP["GitStatusPopup\n(branch, ahead/behind)"] ``` +#### Sync States + +| State | Indicator | Color | Trigger | +|-------|-----------|-------|---------| +| `idle` | Synced / Synced Xm ago | green | Successful sync | +| `syncing` | Syncing... | blue | Pull/push in progress | +| `pull_required` | Pull required | orange | Push rejected (divergence) | +| `conflict` | Conflict | orange | Merge conflicts detected | +| `error` | Sync failed | grey | Network/auth error | + ## Vault Module Structure The vault backend (`src-tauri/src/vault/`) is split into focused submodules: @@ -649,6 +666,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `git_commit` | Stage all + commit | | `git_pull` | Pull from remote | | `git_push` | Push to remote | +| `git_remote_status` | Get branch name + ahead/behind counts | | `git_resolve_conflict` | Resolve a merge conflict | | `git_commit_conflict_resolution` | Commit conflict resolution | | `get_file_history` | Last N commits for a file | diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 506ccf12..3b30d470 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,7 +6,8 @@ use crate::claude_cli::{ }; use crate::frontmatter::FrontmatterValue; use crate::git::{ - GitCommit, GitPullResult, GitPushResult, LastCommitInfo, ModifiedFile, PulseCommit, + GitCommit, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ModifiedFile, + PulseCommit, }; use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo}; use crate::indexing::{IndexStatus, IndexingProgress}; @@ -331,6 +332,12 @@ pub fn git_push(vault_path: String) -> Result { git::git_push(&vault_path) } +#[tauri::command] +pub fn git_remote_status(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path); + git::git_remote_status(&vault_path) +} + // ── GitHub commands ───────────────────────────────────────────────────────── #[tauri::command] diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index f8166c95..3094f55f 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -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, GitPushResult}; +pub use remote::{git_pull, git_push, git_remote_status, has_remote, GitPullResult, GitPushResult, GitRemoteStatus}; pub use status::{get_modified_files, ModifiedFile}; use serde::Serialize; diff --git a/src-tauri/src/git/remote.rs b/src-tauri/src/git/remote.rs index 5506ef24..70401f14 100644 --- a/src-tauri/src/git/remote.rs +++ b/src-tauri/src/git/remote.rs @@ -110,6 +110,75 @@ fn parse_updated_files(stdout: &str) -> Vec { .collect() } +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct GitRemoteStatus { + pub branch: String, + pub ahead: u32, + pub behind: u32, + #[serde(rename = "hasRemote")] + pub has_remote: bool, +} + +/// Get the current branch name, and how many commits ahead/behind the upstream. +pub fn git_remote_status(vault_path: &str) -> Result { + let vault = Path::new(vault_path); + + if !has_remote(vault_path)? { + let branch = current_branch(vault)?; + return Ok(GitRemoteStatus { + branch, + ahead: 0, + behind: 0, + has_remote: false, + }); + } + + // Fetch latest remote refs (silent, best-effort) + let _ = Command::new("git") + .args(["fetch", "--quiet"]) + .current_dir(vault) + .output(); + + let branch = current_branch(vault)?; + + let output = Command::new("git") + .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to run git rev-list: {}", e))?; + + if !output.status.success() { + // No upstream set — report 0/0 + return Ok(GitRemoteStatus { + branch, + ahead: 0, + behind: 0, + has_remote: true, + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let parts: Vec<&str> = stdout.trim().split('\t').collect(); + let ahead = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); + let behind = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0); + + Ok(GitRemoteStatus { + branch, + ahead, + behind, + has_remote: true, + }) +} + +fn current_branch(vault: &Path) -> Result { + let output = Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(vault) + .output() + .map_err(|e| format!("Failed to get branch: {}", e))?; + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct GitPushResult { pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error" @@ -391,6 +460,90 @@ hint: have locally."#; assert!(result.message.contains("Pull first")); } + #[test] + fn test_git_remote_status_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 status = git_remote_status(vp).unwrap(); + assert!(!status.has_remote); + assert_eq!(status.ahead, 0); + assert_eq!(status.behind, 0); + } + + #[test] + fn test_git_remote_status_up_to_date() { + 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(); + git_push(vp_a).unwrap(); + + let status = git_remote_status(vp_a).unwrap(); + assert!(status.has_remote); + assert_eq!(status.ahead, 0); + assert_eq!(status.behind, 0); + } + + #[test] + fn test_git_remote_status_ahead() { + 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(); + git_push(vp_a).unwrap(); + + // Make a new commit without pushing + fs::write(clone_a.path().join("note.md"), "# Updated\n").unwrap(); + git_commit(vp_a, "update").unwrap(); + + let status = git_remote_status(vp_a).unwrap(); + assert_eq!(status.ahead, 1); + assert_eq!(status.behind, 0); + } + + #[test] + fn test_git_remote_status_behind() { + 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(); + + fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap(); + git_commit(vp_a, "initial").unwrap(); + git_push(vp_a).unwrap(); + + git_pull(vp_b).unwrap(); + fs::write(clone_b.path().join("note.md"), "# B update\n").unwrap(); + git_commit(vp_b, "from B").unwrap(); + git_push(vp_b).unwrap(); + + // A is now behind by 1 + let status = git_remote_status(vp_a).unwrap(); + assert_eq!(status.behind, 1); + assert_eq!(status.ahead, 0); + } + + #[test] + fn test_git_remote_status_serialization() { + let status = GitRemoteStatus { + branch: "main".to_string(), + ahead: 2, + behind: 1, + has_remote: true, + }; + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("\"hasRemote\"")); + let parsed: GitRemoteStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.branch, "main"); + assert_eq!(parsed.ahead, 2); + } + #[test] fn test_git_pull_result_serialization() { let result = GitPullResult { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 82dd59ed..a59ebdc1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -130,6 +130,7 @@ pub fn run() { commands::get_last_commit_info, commands::git_pull, commands::git_push, + commands::git_remote_status, commands::get_conflict_files, commands::get_conflict_mode, commands::git_resolve_conflict, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index a539a30c..8a754c7d 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -49,6 +49,7 @@ const VAULT_RESTORE_GETTING_STARTED: &str = "vault-restore-getting-started"; const VAULT_NEW_THEME: &str = "vault-new-theme"; const VAULT_RESTORE_DEFAULT_THEMES: &str = "vault-restore-default-themes"; const VAULT_COMMIT_PUSH: &str = "vault-commit-push"; +const VAULT_PULL: &str = "vault-pull"; const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts"; const VAULT_VIEW_CHANGES: &str = "vault-view-changes"; const VAULT_INSTALL_MCP: &str = "vault-install-mcp"; @@ -95,6 +96,7 @@ const CUSTOM_IDS: &[&str] = &[ VAULT_NEW_THEME, VAULT_RESTORE_DEFAULT_THEMES, VAULT_COMMIT_PUSH, + VAULT_PULL, VAULT_RESOLVE_CONFLICTS, VAULT_VIEW_CHANGES, VAULT_INSTALL_MCP, @@ -353,6 +355,9 @@ fn build_vault_menu(app: &App) -> MenuResult { let commit_push = MenuItemBuilder::new("Commit & Push") .id(VAULT_COMMIT_PUSH) .build(app)?; + let pull = MenuItemBuilder::new("Pull from Remote") + .id(VAULT_PULL) + .build(app)?; let resolve_conflicts = MenuItemBuilder::new("Resolve Conflicts") .id(VAULT_RESOLVE_CONFLICTS) .enabled(false) @@ -382,6 +387,7 @@ fn build_vault_menu(app: &App) -> MenuResult { .item(&restore_default_themes) .separator() .item(&commit_push) + .item(&pull) .item(&resolve_conflicts) .item(&view_changes) .separator() @@ -504,6 +510,7 @@ mod tests { VAULT_NEW_THEME, VAULT_RESTORE_DEFAULT_THEMES, VAULT_COMMIT_PUSH, + VAULT_PULL, VAULT_RESOLVE_CONFLICTS, VAULT_VIEW_CHANGES, VAULT_INSTALL_MCP, diff --git a/src/App.tsx b/src/App.tsx index 35a73c7e..b3cf2c30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -157,6 +157,35 @@ function App() { dialogs.closeConflictResolver() }, [autoSync, dialogs]) + /** Resolve a single file conflict from the in-editor banner. */ + const handleResolveConflictInline = useCallback(async (filePath: string, strategy: 'ours' | 'theirs') => { + try { + const relativePath = filePath.replace(resolvedPath + '/', '') + const call = isTauri() ? invoke : mockInvoke + await call('git_resolve_conflict', { vaultPath: resolvedPath, file: relativePath, strategy }) + // Reload the note content to show the resolved version + const content = await (isTauri() ? invoke : mockInvoke)('get_note_content', { path: filePath }) + // Check remaining conflicts + const remaining = await (isTauri() ? invoke : mockInvoke)('get_conflict_files', { vaultPath: resolvedPath }) + if (remaining.length === 0) { + // All resolved — auto-commit the merge and push + await (isTauri() ? invoke : mockInvoke)('git_commit_conflict_resolution', { vaultPath: resolvedPath }) + vault.reloadVault() + autoSync.triggerSync() + setToastMessage('All conflicts resolved — merge committed') + } else { + void content // content reload happens via vault reload + vault.reloadVault() + setToastMessage(`Resolved — ${remaining.length} conflict${remaining.length > 1 ? 's' : ''} remaining`) + } + } catch (err) { + setToastMessage(`Failed to resolve conflict: ${err}`) + } + }, [resolvedPath, vault, autoSync, setToastMessage]) + + const handleKeepMine = useCallback((path: string) => handleResolveConflictInline(path, 'ours'), [handleResolveConflictInline]) + const handleKeepTheirs = useCallback((path: string) => handleResolveConflictInline(path, 'theirs'), [handleResolveConflictInline]) + // Ref bridges handleContentChange (created after notes) into useNoteActions. // Read at callback time, so it's always current when user presses Cmd+N. const contentChangeRef = useRef<(path: string, content: string) => void>(() => {}) @@ -362,7 +391,7 @@ function App() { } }, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths]) - const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage }) + const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected }) const entryActions = useEntryActions({ entries: vault.entries, updateEntry: vault.updateEntry, @@ -470,6 +499,7 @@ function App() { onTrashNote: entryActions.handleTrashNote, onRestoreNote: entryActions.handleRestoreNote, onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, onCommitPush: commitFlow.openCommitDialog, + onPull: autoSync.triggerSync, onResolveConflicts: handleOpenConflictResolver, onSetViewMode: setViewMode, onToggleInspector: () => layout.setInspectorCollapsed(c => !c), @@ -627,6 +657,9 @@ function App() { onVaultChanged={handleAgentVaultChanged} onSetNoteIcon={handleSetNoteIcon} onRemoveNoteIcon={handleRemoveNoteIcon} + isConflicted={!!notes.activeTabPath && autoSync.conflictFiles.some(f => notes.activeTabPath?.endsWith(f))} + onKeepMine={handleKeepMine} + onKeepTheirs={handleKeepTheirs} /> @@ -642,7 +675,7 @@ function App() { /> )} - handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} /> setToastMessage(null)} /> diff --git a/src/components/ConflictNoteBanner.test.tsx b/src/components/ConflictNoteBanner.test.tsx new file mode 100644 index 00000000..da57affb --- /dev/null +++ b/src/components/ConflictNoteBanner.test.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { ConflictNoteBanner } from './ConflictNoteBanner' + +describe('ConflictNoteBanner', () => { + it('renders conflict message', () => { + render() + expect(screen.getByText('This note has a merge conflict')).toBeInTheDocument() + }) + + it('calls onKeepMine when clicking Keep mine button', () => { + const onKeepMine = vi.fn() + render() + fireEvent.click(screen.getByTestId('conflict-keep-mine-btn')) + expect(onKeepMine).toHaveBeenCalledOnce() + }) + + it('calls onKeepTheirs when clicking Keep theirs button', () => { + const onKeepTheirs = vi.fn() + render() + fireEvent.click(screen.getByTestId('conflict-keep-theirs-btn')) + expect(onKeepTheirs).toHaveBeenCalledOnce() + }) + + it('has the correct test id', () => { + render() + expect(screen.getByTestId('conflict-note-banner')).toBeInTheDocument() + }) +}) diff --git a/src/components/ConflictNoteBanner.tsx b/src/components/ConflictNoteBanner.tsx new file mode 100644 index 00000000..397f45d5 --- /dev/null +++ b/src/components/ConflictNoteBanner.tsx @@ -0,0 +1,68 @@ +import { AlertTriangle } from 'lucide-react' + +interface ConflictNoteBannerProps { + onKeepMine: () => void + onKeepTheirs: () => void +} + +export function ConflictNoteBanner({ onKeepMine, onKeepTheirs }: ConflictNoteBannerProps) { + return ( +
+ + This note has a merge conflict +
+ + +
+
+ ) +} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index a5e39966..94fb8e28 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -77,6 +77,12 @@ interface EditorProps { onSetNoteIcon?: (path: string, emoji: string) => void /** Called when user removes an emoji icon from a note. */ onRemoveNoteIcon?: (path: string) => void + /** Whether the active note has a merge conflict. */ + isConflicted?: boolean + /** Resolve conflict by keeping the local version. */ + onKeepMine?: (path: string) => void + /** Resolve conflict by keeping the remote version. */ + onKeepTheirs?: (path: string) => void } function useEditorModeExclusion({ @@ -224,6 +230,7 @@ export const Editor = memo(function Editor(props: EditorProps) { canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed, isDarkTheme, onFileCreated, onFileModified, onVaultChanged, onSetNoteIcon, onRemoveNoteIcon, + isConflicted, onKeepMine, onKeepTheirs, } = props const { @@ -291,6 +298,9 @@ export const Editor = memo(function Editor(props: EditorProps) { onTitleChange={onTitleSync} onSetNoteIcon={onSetNoteIcon} onRemoveNoteIcon={onRemoveNoteIcon} + isConflicted={isConflicted} + onKeepMine={onKeepMine} + onKeepTheirs={onKeepTheirs} /> } {(showAIChat || !inspectorCollapsed) && } diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index 07a1cdd7..edd4d324 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -8,6 +8,7 @@ import { TitleField } from './TitleField' import { NoteIcon } from './NoteIcon' import { TrashedNoteBanner } from './TrashedNoteBanner' import { ArchivedNoteBanner } from './ArchivedNoteBanner' +import { ConflictNoteBanner } from './ConflictNoteBanner' import { RawEditorView } from './RawEditorView' import { countWords } from '../utils/wikilinks' import { SingleEditorView } from './SingleEditorView' @@ -54,6 +55,12 @@ interface EditorContentProps { onSetNoteIcon?: (path: string, emoji: string) => void /** Called when user removes an emoji icon. */ onRemoveNoteIcon?: (path: string) => void + /** Whether the active note has a merge conflict. */ + isConflicted?: boolean + /** Resolve conflict by keeping the local version. */ + onKeepMine?: (path: string) => void + /** Resolve conflict by keeping the remote version. */ + onKeepTheirs?: (path: string) => void } function EditorLoadingSkeleton() { @@ -151,6 +158,7 @@ export function EditorContent({ onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme, onDeleteNote, rawLatestContentRef, onTitleChange, onSetNoteIcon, onRemoveNoteIcon, + isConflicted, onKeepMine, onKeepTheirs, ...breadcrumbProps }: EditorContentProps) { const isTrashed = activeTab?.entry.trashed ?? false @@ -183,6 +191,12 @@ export function EditorContent({ {activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && ( breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} /> )} + {activeTab && isConflicted && ( + onKeepMine?.(activeTab.entry.path)} + onKeepTheirs={() => onKeepTheirs?.(activeTab.entry.path)} + /> + )} {diffMode && } {showEditor && activeTab && ( diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index a089abd6..202118b6 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -428,6 +428,40 @@ describe('StatusBar', () => { expect(onReindexVault).toHaveBeenCalledOnce() }) + it('shows Pull required label when syncStatus is pull_required', () => { + render( + + ) + expect(screen.getByText('Pull required')).toBeInTheDocument() + }) + + it('calls onPullAndPush when clicking Pull required badge', () => { + const onPullAndPush = vi.fn() + render( + + ) + fireEvent.click(screen.getByTestId('status-sync')) + expect(onPullAndPush).toHaveBeenCalledOnce() + }) + + it('shows git status popup when clicking idle sync badge', () => { + render( + + ) + fireEvent.click(screen.getByTestId('status-sync')) + expect(screen.getByTestId('git-status-popup')).toBeInTheDocument() + expect(screen.getByText('main')).toBeInTheDocument() + expect(screen.getByText(/2 ahead/)).toBeInTheDocument() + expect(screen.getByText(/1 behind/)).toBeInTheDocument() + }) + it('hides indexed time badge when no lastIndexedTime', () => { render( void + onPullAndPush?: () => void onOpenConflictResolver?: () => void zoomLevel?: number onZoomReset?: () => void @@ -159,10 +161,10 @@ function VaultMenu({ vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onConn 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 -const SYNC_ICON_MAP: Record = { syncing: Loader2, conflict: AlertTriangle } +const SYNC_ICON_MAP: Record = { syncing: Loader2, conflict: AlertTriangle, pull_required: ArrowDown } -const SYNC_LABELS: Record = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed' } -const SYNC_COLORS: Record = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)' } +const SYNC_LABELS: Record = { syncing: 'Syncing…', conflict: 'Conflict', error: 'Sync failed', pull_required: 'Pull required' } +const SYNC_COLORS: Record = { conflict: 'var(--accent-orange)', error: 'var(--muted-foreground)', pull_required: 'var(--accent-orange)' } function formatElapsedSync(lastSyncTime: number | null): string { if (!lastSyncTime) return 'Not synced' @@ -201,21 +203,114 @@ function CommitBadge({ info }: { info: LastCommitInfo }) { ) } -function SyncBadge({ status, lastSyncTime, onTriggerSync, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; onTriggerSync?: () => void; onOpenConflictResolver?: () => void }) { +function syncBadgeTitle(status: SyncStatus): string { + if (status === 'conflict') return 'Click to resolve conflicts' + if (status === 'syncing') return 'Syncing…' + if (status === 'pull_required') return 'Click to pull from remote and push' + return 'Click to sync now' +} + +function SyncBadge({ status, lastSyncTime, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver }: { status: SyncStatus; lastSyncTime: number | null; remoteStatus?: GitRemoteStatus | null; onTriggerSync?: () => void; onPullAndPush?: () => void; onOpenConflictResolver?: () => void }) { + const [showPopup, setShowPopup] = useState(false) + const popupRef = useRef(null) const SyncIcon = SYNC_ICON_MAP[status] ?? RefreshCw const isSyncing = status === 'syncing' const isConflict = status === 'conflict' - const handleClick = isConflict ? onOpenConflictResolver : onTriggerSync + const isPullRequired = status === 'pull_required' + + const handleClick = () => { + if (isConflict) { onOpenConflictResolver?.(); return } + if (isPullRequired) { onPullAndPush?.(); return } + setShowPopup(v => !v) + } + + useEffect(() => { + if (!showPopup) return + const handleOutside = (e: MouseEvent) => { + if (popupRef.current && !popupRef.current.contains(e.target as Node)) setShowPopup(false) + } + document.addEventListener('mousedown', handleOutside) + return () => document.removeEventListener('mousedown', handleOutside) + }, [showPopup]) + return ( - + + {formatSyncLabel(status, lastSyncTime)} + + {showPopup && ( + setShowPopup(false)} + /> + )} + + ) +} + +function GitStatusPopup({ status, remoteStatus, onPull, onClose }: { status: SyncStatus; remoteStatus: GitRemoteStatus | null; onPull?: () => void; onClose: () => void }) { + const branch = remoteStatus?.branch || '—' + const ahead = remoteStatus?.ahead ?? 0 + const behind = remoteStatus?.behind ?? 0 + const hasRemote = remoteStatus?.hasRemote ?? false + + return ( +
- {formatSyncLabel(status, lastSyncTime)} - +
+ + {branch} +
+ + {hasRemote && ( +
+ {ahead > 0 && 1 ? 's' : ''} ahead of remote`}>↑ {ahead} ahead} + {behind > 0 && 1 ? 's' : ''} behind remote`} style={{ color: 'var(--accent-orange)' }}>↓ {behind} behind} + {ahead === 0 && behind === 0 && In sync with remote} +
+ )} + + {!hasRemote && ( +
No remote configured
+ )} + +
+ Status: {status === 'idle' ? 'Synced' : status === 'pull_required' ? 'Pull required' : status === 'conflict' ? 'Conflicts' : status === 'error' ? 'Error' : status === 'syncing' ? 'Syncing…' : status} +
+ + {hasRemote && ( +
+ +
+ )} +
) } @@ -356,7 +451,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () => ) } -export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) { +export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) { const [, setTick] = useState(0) useEffect(() => { const id = setInterval(() => setTick((t) => t + 1), 30_000) @@ -378,7 +473,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS onMouseLeave={onCheckForUpdates ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined} >{buildNumber ?? 'b?'}
| - + {lastCommitInfo && } diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index bd9342ba..e6cc49ed 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -31,6 +31,7 @@ interface AppCommandsConfig { onArchiveNote: (path: string) => void onUnarchiveNote: (path: string) => void onCommitPush: () => void + onPull?: () => void onResolveConflicts?: () => void onSetViewMode: (mode: ViewMode) => void onToggleInspector: () => void @@ -159,6 +160,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onCreateTheme: config.onCreateTheme, onRestoreDefaultThemes: config.onRestoreDefaultThemes, onCommitPush: config.onCommitPush, + onPull: config.onPull, onResolveConflicts: config.onResolveConflicts, onViewChanges: viewChanges, onInstallMcp: config.onInstallMcp, @@ -188,6 +190,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onArchiveNote: config.onArchiveNote, onUnarchiveNote: config.onUnarchiveNote, onCommitPush: config.onCommitPush, + onPull: config.onPull, onResolveConflicts: config.onResolveConflicts, onSetViewMode: config.onSetViewMode, onToggleInspector: config.onToggleInspector, diff --git a/src/hooks/useAutoSync.ts b/src/hooks/useAutoSync.ts index b4d148e3..faac657c 100644 --- a/src/hooks/useAutoSync.ts +++ b/src/hooks/useAutoSync.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' -import type { GitPullResult, LastCommitInfo, SyncStatus } from '../types' +import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types' const DEFAULT_INTERVAL_MS = 5 * 60_000 @@ -23,11 +23,16 @@ export interface AutoSyncState { lastSyncTime: number | null conflictFiles: string[] lastCommitInfo: LastCommitInfo | null + remoteStatus: GitRemoteStatus | null triggerSync: () => void + /** Pull from remote, then push if there are local commits ahead. */ + pullAndPush: () => void /** Pause auto-pull (e.g. while conflict resolver modal is open). */ pausePull: () => void /** Resume auto-pull after pausing. */ resumePull: () => void + /** Notify that a push was rejected so the status updates to pull_required. */ + handlePushRejected: () => void } export function useAutoSync({ @@ -42,11 +47,22 @@ export function useAutoSync({ const [lastSyncTime, setLastSyncTime] = useState(null) const [conflictFiles, setConflictFiles] = useState([]) const [lastCommitInfo, setLastCommitInfo] = useState(null) + const [remoteStatus, setRemoteStatus] = useState(null) const syncingRef = useRef(false) const pauseRef = useRef(false) const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast }) callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast } + const refreshRemoteStatus = useCallback(async () => { + try { + const status = await tauriCall('git_remote_status', { vaultPath }) + setRemoteStatus(status) + return status + } catch { + return null + } + }, [vaultPath]) + /** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */ const checkExistingConflicts = useCallback(async (): Promise => { try { @@ -63,6 +79,12 @@ export function useAutoSync({ return false }, [vaultPath]) + const refreshCommitInfo = useCallback(() => { + tauriCall('get_last_commit_info', { vaultPath }) + .then(info => setLastCommitInfo(info)) + .catch(() => {}) + }, [vaultPath]) + const performPull = useCallback(async () => { if (syncingRef.current || pauseRef.current) return syncingRef.current = true @@ -71,9 +93,7 @@ export function useAutoSync({ try { const result = await tauriCall('git_pull', { vaultPath }) setLastSyncTime(Date.now()) - tauriCall('get_last_commit_info', { vaultPath }) - .then(info => setLastCommitInfo(info)) - .catch(() => {}) + refreshCommitInfo() if (result.status === 'updated') { setSyncStatus('idle') @@ -96,20 +116,84 @@ export function useAutoSync({ setSyncStatus('idle') setConflictFiles([]) } + + // Refresh remote status after pull + refreshRemoteStatus() } catch { setSyncStatus('error') setLastSyncTime(Date.now()) } finally { syncingRef.current = false } - }, [vaultPath, checkExistingConflicts]) + }, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus]) + + /** Pull from remote, then auto-push if successful. Used for divergence recovery. */ + const pullAndPush = useCallback(async () => { + if (syncingRef.current) return + syncingRef.current = true + setSyncStatus('syncing') + + try { + const pullResult = await tauriCall('git_pull', { vaultPath }) + setLastSyncTime(Date.now()) + refreshCommitInfo() + + if (pullResult.status === 'conflict') { + setSyncStatus('conflict') + setConflictFiles(pullResult.conflictFiles) + callbacksRef.current.onConflict(pullResult.conflictFiles) + return + } + + if (pullResult.status === 'error') { + const hasConflicts = await checkExistingConflicts() + if (!hasConflicts) { + setSyncStatus('error') + callbacksRef.current.onToast('Pull failed: ' + pullResult.message) + } + return + } + + if (pullResult.status === 'updated') { + callbacksRef.current.onVaultUpdated() + callbacksRef.current.onSyncUpdated?.() + } + + // Now push + const pushResult = await tauriCall('git_push', { vaultPath }) + if (pushResult.status === 'ok') { + setSyncStatus('idle') + setConflictFiles([]) + callbacksRef.current.onToast('Pulled and pushed successfully') + } else if (pushResult.status === 'rejected') { + // Still diverged — shouldn't happen after pull but handle gracefully + setSyncStatus('pull_required') + callbacksRef.current.onToast('Push still rejected after pull — try again') + } else { + setSyncStatus('error') + callbacksRef.current.onToast(pushResult.message) + } + + refreshRemoteStatus() + } catch { + setSyncStatus('error') + setLastSyncTime(Date.now()) + } finally { + syncingRef.current = false + } + }, [vaultPath, checkExistingConflicts, refreshCommitInfo, refreshRemoteStatus]) + + const handlePushRejected = useCallback(() => { + setSyncStatus('pull_required') + }, []) // Check for pre-existing conflicts on mount, then pull useEffect(() => { checkExistingConflicts().then(hasConflicts => { if (!hasConflicts) performPull() }) - }, [checkExistingConflicts, performPull]) + refreshRemoteStatus() + }, [checkExistingConflicts, performPull, refreshRemoteStatus]) // Pull on window focus (app foreground) useEffect(() => { @@ -128,5 +212,5 @@ export function useAutoSync({ const pausePull = useCallback(() => { pauseRef.current = true }, []) const resumePull = useCallback(() => { pauseRef.current = false }, []) - return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, triggerSync: performPull, pausePull, resumePull } + return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync: performPull, pullAndPush, pausePull, resumePull, handlePushRejected } } diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index e7571263..398c2810 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -44,6 +44,7 @@ interface CommandRegistryConfig { onArchiveNote: (path: string) => void onUnarchiveNote: (path: string) => void onCommitPush: () => void + onPull?: () => void onResolveConflicts?: () => void onSetViewMode: (mode: ViewMode) => void onToggleInspector: () => void @@ -201,7 +202,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction activeTabPath, entries, modifiedCount, onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote, - onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, + onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, activeNoteModified, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab, @@ -285,6 +286,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction // Git { id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'save', 'sync'], enabled: modifiedCount > 0, execute: onCommitPush }, + { id: 'git-pull', label: 'Pull from Remote', group: 'Git', keywords: ['git', 'pull', 'fetch', 'download', 'sync', 'remote'], enabled: true, execute: () => onPull?.() }, { id: 'resolve-conflicts', label: 'Resolve Conflicts', group: 'Git', keywords: ['conflict', 'merge', 'git', 'sync'], enabled: true, execute: () => onResolveConflicts?.() }, { id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) }, @@ -320,15 +322,14 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount, activeNoteModified, onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote, - onCommitPush, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, + onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCheckForUpdates, onZoomIn, onZoomOut, onZoomReset, zoomLevel, onSelect, onOpenDailyNote, onCloseTab, onGoBack, onGoForward, canGoBack, canGoForward, vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes, onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount, - mcpStatus, onInstallMcp, - onEmptyTrash, trashedCount, + mcpStatus, onInstallMcp, onEmptyTrash, trashedCount, onReindexVault, onReloadVault, onRepairVault, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, isSectionGroup, noteListFilter, onSetNoteListFilter, diff --git a/src/hooks/useCommitFlow.test.ts b/src/hooks/useCommitFlow.test.ts index a3b6f1b1..8a26e628 100644 --- a/src/hooks/useCommitFlow.test.ts +++ b/src/hooks/useCommitFlow.test.ts @@ -7,16 +7,18 @@ describe('useCommitFlow', () => { let loadModifiedFiles: vi.Mock let commitAndPush: vi.Mock let setToastMessage: vi.Mock + let onPushRejected: vi.Mock beforeEach(() => { savePending = vi.fn().mockResolvedValue(undefined) loadModifiedFiles = vi.fn().mockResolvedValue(undefined) - commitAndPush = vi.fn().mockResolvedValue('Committed and pushed') + commitAndPush = vi.fn().mockResolvedValue({ status: 'ok', message: 'Pushed to remote' }) setToastMessage = vi.fn() + onPushRejected = vi.fn() }) function renderCommitFlow() { - return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage })) + return renderHook(() => useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected })) } it('openCommitDialog saves pending, refreshes files, then opens dialog', async () => { @@ -46,6 +48,18 @@ describe('useCommitFlow', () => { expect(result.current.showCommitDialog).toBe(false) }) + it('handleCommitPush calls onPushRejected when push is rejected', async () => { + commitAndPush.mockResolvedValueOnce({ status: 'rejected', message: 'Push rejected' }) + const { result } = renderCommitFlow() + + await act(async () => { + await result.current.handleCommitPush('test message') + }) + + expect(onPushRejected).toHaveBeenCalledTimes(1) + expect(setToastMessage).toHaveBeenCalledWith(expect.stringContaining('push rejected')) + }) + it('handleCommitPush shows error toast on failure', async () => { commitAndPush.mockRejectedValueOnce(new Error('push failed')) const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) diff --git a/src/hooks/useCommitFlow.ts b/src/hooks/useCommitFlow.ts index 3cb3c1f1..b11a09d3 100644 --- a/src/hooks/useCommitFlow.ts +++ b/src/hooks/useCommitFlow.ts @@ -1,14 +1,16 @@ import { useCallback, useState } from 'react' +import type { GitPushResult } from '../types' interface CommitFlowConfig { savePending: () => Promise loadModifiedFiles: () => Promise - commitAndPush: (message: string) => Promise + commitAndPush: (message: string) => Promise setToastMessage: (msg: string | null) => void + onPushRejected?: () => void } /** Manages the Commit & Push dialog state and the save→commit→push flow. */ -export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage }: CommitFlowConfig) { +export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, setToastMessage, onPushRejected }: CommitFlowConfig) { const [showCommitDialog, setShowCommitDialog] = useState(false) const openCommitDialog = useCallback(async () => { @@ -22,13 +24,20 @@ export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, s try { await savePending() const result = await commitAndPush(message) - setToastMessage(result) + if (result.status === 'ok') { + setToastMessage('Committed and pushed') + } else if (result.status === 'rejected') { + setToastMessage('Committed, but push rejected — remote has new commits. Pull first.') + onPushRejected?.() + } else { + setToastMessage(result.message) + } loadModifiedFiles() } catch (err) { console.error('Commit failed:', err) setToastMessage(`Commit failed: ${err}`) } - }, [savePending, commitAndPush, loadModifiedFiles, setToastMessage]) + }, [savePending, commitAndPush, loadModifiedFiles, setToastMessage, onPushRejected]) const closeCommitDialog = useCallback(() => setShowCommitDialog(false), []) diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts index f29a43f7..36ef5a0d 100644 --- a/src/hooks/useMenuEvents.test.ts +++ b/src/hooks/useMenuEvents.test.ts @@ -31,6 +31,7 @@ function makeHandlers(): MenuEventHandlers { onCreateTheme: vi.fn(), onRestoreDefaultThemes: vi.fn(), onCommitPush: vi.fn(), + onPull: vi.fn(), onResolveConflicts: vi.fn(), onViewChanges: vi.fn(), onInstallMcp: vi.fn(), @@ -285,6 +286,12 @@ describe('dispatchMenuEvent', () => { expect(h.onCommitPush).toHaveBeenCalled() }) + it('vault-pull triggers pull', () => { + const h = makeHandlers() + dispatchMenuEvent('vault-pull', h) + expect(h.onPull).toHaveBeenCalled() + }) + it('vault-resolve-conflicts triggers resolve conflicts', () => { const h = makeHandlers() dispatchMenuEvent('vault-resolve-conflicts', h) diff --git a/src/hooks/useMenuEvents.ts b/src/hooks/useMenuEvents.ts index 9b79a2ec..078d55c6 100644 --- a/src/hooks/useMenuEvents.ts +++ b/src/hooks/useMenuEvents.ts @@ -32,6 +32,7 @@ export interface MenuEventHandlers { onCreateTheme?: () => void onRestoreDefaultThemes?: () => void onCommitPush?: () => void + onPull?: () => void onResolveConflicts?: () => void onViewChanges?: () => void onInstallMcp?: () => void @@ -84,7 +85,7 @@ type OptionalHandler = | 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted' | 'onCreateTheme' | 'onRestoreDefaultThemes' - | 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault' + | 'onCommitPush' | 'onPull' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onReloadVault' | 'onRepairVault' | 'onEmptyTrash' | 'onReopenClosedTab' | 'onOpenInNewWindow' @@ -103,6 +104,7 @@ const OPTIONAL_EVENT_MAP: Record = { 'vault-new-theme': 'onCreateTheme', 'vault-restore-default-themes': 'onRestoreDefaultThemes', 'vault-commit-push': 'onCommitPush', + 'vault-pull': 'onPull', 'vault-resolve-conflicts': 'onResolveConflicts', 'vault-view-changes': 'onViewChanges', 'vault-install-mcp': 'onInstallMcp', diff --git a/src/hooks/useVaultLoader.test.ts b/src/hooks/useVaultLoader.test.ts index ebd36602..0756a9f7 100644 --- a/src/hooks/useVaultLoader.test.ts +++ b/src/hooks/useVaultLoader.test.ts @@ -362,15 +362,15 @@ describe('useVaultLoader', () => { expect(result.current.entries).toHaveLength(1) }) - let response = '' + let response: { status: string; message: string } = { status: '', message: '' } await act(async () => { response = await result.current.commitAndPush('test commit') }) - expect(response).toBe('Committed and pushed') + expect(response.status).toBe('ok') }) - it('returns actionable message when push is rejected', async () => { + it('returns rejected status 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) @@ -383,16 +383,16 @@ describe('useVaultLoader', () => { const { result } = renderHook(() => useVaultLoader('/vault')) await waitFor(() => { expect(result.current.entries).toHaveLength(1) }) - let response = '' + let response: { status: string; message: string } = { status: '', message: '' } await act(async () => { response = await result.current.commitAndPush('test commit') }) - expect(response).toContain('Pull first') - expect(response).not.toBe('Committed and pushed') + expect(response.status).toBe('rejected') + expect(response.message).toContain('Pull first') }) - it('returns network error message on network failure', async () => { + it('returns network error status 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) @@ -405,12 +405,13 @@ describe('useVaultLoader', () => { const { result } = renderHook(() => useVaultLoader('/vault')) await waitFor(() => { expect(result.current.entries).toHaveLength(1) }) - let response = '' + let response: { status: string; message: string } = { status: '', message: '' } await act(async () => { response = await result.current.commitAndPush('test commit') }) - expect(response).toContain('network error') + expect(response.status).toBe('network_error') + expect(response.message).toContain('network error') }) }) diff --git a/src/hooks/useVaultLoader.ts b/src/hooks/useVaultLoader.ts index afa34d10..b52e9f70 100644 --- a/src/hooks/useVaultLoader.ts +++ b/src/hooks/useVaultLoader.ts @@ -15,15 +15,13 @@ async function loadVaultData(vaultPath: string) { return { entries } } -async function commitWithPush(vaultPath: string, message: string): Promise { +async function commitWithPush(vaultPath: string, message: string): Promise { if (!isTauri()) { await mockInvoke('git_commit', { message }) - const pushResult = await mockInvoke('git_push', {}) - return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message + return mockInvoke('git_push', {}) } await invoke('git_commit', { vaultPath, message }) - const pushResult = await invoke('git_push', { vaultPath }) - return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message + return invoke('git_push', { vaultPath }) } function useNewNoteTracker() { @@ -156,7 +154,7 @@ export function useVaultLoader(vaultPath: string) { const getNoteStatus = useCallback((path: string): NoteStatus => resolveNoteStatus(path, tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths), [tracker.newPaths, modifiedFiles, pendingSave.pendingSavePaths, unsaved.unsavedPaths]) - const commitAndPush = useCallback((message: string): Promise => + const commitAndPush = useCallback((message: string): Promise => commitWithPush(vaultPath, message), [vaultPath]) const reloadVault = useCallback( diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index d6463e9c..a148f9fb 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -3,7 +3,7 @@ * Each handler simulates a Tauri backend command. */ -import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types' +import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types' import { MOCK_CONTENT } from './mock-content' import { MOCK_ENTRIES } from './mock-entries' @@ -180,6 +180,7 @@ export const mockHandlers: Record any> = { 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: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }), + git_remote_status: (): GitRemoteStatus => ({ branch: 'main', ahead: 0, behind: 0, hasRemote: true }), get_vault_pulse: (args: { limit?: number }): PulseCommit[] => { const limit = args.limit ?? 30 const ts = Math.floor(Date.now() / 1000) diff --git a/src/types.ts b/src/types.ts index d7c9df80..5c060cb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,7 +83,14 @@ export interface GitPushResult { message: string } -export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict' +export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict' | 'pull_required' + +export interface GitRemoteStatus { + branch: string + ahead: number + behind: number + hasRemote: boolean +} export interface DeviceFlowStart { device_code: string diff --git a/tests/smoke/git-divergence-conflicts.spec.ts b/tests/smoke/git-divergence-conflicts.spec.ts new file mode 100644 index 00000000..06fe330e --- /dev/null +++ b/tests/smoke/git-divergence-conflicts.spec.ts @@ -0,0 +1,90 @@ +import { test, expect } from '@playwright/test' +import { openCommandPalette, executeCommand } from './helpers' + +test.describe('Git divergence, conflicts, and manual pull', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('push rejection sets pull_required status in bottom bar', async ({ page }) => { + // Override git_push mock to return rejected + await page.evaluate(() => { + window.__mockHandlers!.git_push = () => ({ + status: 'rejected', + message: 'Push rejected: remote has new commits. Pull first, then push.', + }) + }) + + // Commit to trigger push + 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() + + // Verify toast shows rejection message + const toast = page.locator('.fixed.bottom-8') + await expect(toast).toContainText('push rejected', { timeout: 5000 }) + + // Verify "Pull required" label appears in the status bar + const syncBadge = page.getByTestId('status-sync') + await expect(syncBadge).toContainText('Pull required', { timeout: 5000 }) + }) + + test('Pull from Remote command exists in command palette', async ({ page }) => { + await openCommandPalette(page) + + const input = page.locator('input[placeholder="Type a command..."]') + await input.fill('Pull') + + // Verify the Pull from Remote command appears as the selected item + const match = page.locator('[data-selected="true"]').first() + await expect(match).toContainText('Pull from Remote', { timeout: 3000 }) + }) + + test('git status popup shows branch and sync info when clicking sync badge', async ({ page }) => { + // Override git_remote_status to return data with ahead/behind + await page.evaluate(() => { + window.__mockHandlers!.git_remote_status = () => ({ + branch: 'main', + ahead: 3, + behind: 1, + hasRemote: true, + }) + }) + + // Trigger a sync so the remote status gets fetched + const syncBadge = page.getByTestId('status-sync') + await syncBadge.click() + + // The popup should appear + const popup = page.getByTestId('git-status-popup') + await expect(popup).toBeVisible({ timeout: 3000 }) + await expect(popup).toContainText('main') + }) + + test('conflict badge shows count when conflicts exist', async ({ page }) => { + // Override git_pull to return conflicts + await page.evaluate(() => { + window.__mockHandlers!.git_pull = () => ({ + status: 'conflict', + message: 'Merge conflict in 2 file(s)', + updatedFiles: [], + conflictFiles: ['note-a.md', 'note-b.md'], + }) + window.__mockHandlers!.get_conflict_files = () => ['note-a.md', 'note-b.md'] + }) + + // Trigger a sync + await openCommandPalette(page) + await executeCommand(page, 'Pull from Remote') + + // Verify conflict count badge appears + const conflictBadge = page.getByTestId('status-conflict-count') + await expect(conflictBadge).toBeVisible({ timeout: 5000 }) + await expect(conflictBadge).toContainText('2 conflicts') + }) +})