From 05dca72ef303ca3ffa80b16069b8247e4fd41f13 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 19 Mar 2026 09:51:06 +0100 Subject: [PATCH] feat: handle git divergence, conflicts, and manual pull from within Laputa When push is rejected due to remote having newer commits, the bottom bar now shows "Pull required" (orange). Clicking it pulls then auto-pushes. Conflicted notes show an inline banner with "Keep mine" / "Keep theirs" buttons. A new "Pull from Remote" command is available in Cmd+K and the Vault menu. Clicking the sync badge opens a popup showing branch name, ahead/behind counts, and a Pull button. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/ABSTRACTIONS.md | 7 + docs/ARCHITECTURE.md | 26 +++- src-tauri/src/commands.rs | 9 +- src-tauri/src/git/mod.rs | 2 +- src-tauri/src/git/remote.rs | 153 +++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/menu.rs | 7 + src/App.tsx | 37 ++++- src/components/ConflictNoteBanner.test.tsx | 29 ++++ src/components/ConflictNoteBanner.tsx | 68 +++++++++ src/components/Editor.tsx | 10 ++ src/components/EditorContent.tsx | 14 ++ src/components/StatusBar.test.tsx | 34 +++++ src/components/StatusBar.tsx | 129 +++++++++++++--- src/hooks/useAppCommands.ts | 3 + src/hooks/useAutoSync.ts | 98 +++++++++++- src/hooks/useCommandRegistry.ts | 9 +- src/hooks/useCommitFlow.test.ts | 18 ++- src/hooks/useCommitFlow.ts | 17 ++- src/hooks/useMenuEvents.test.ts | 7 + src/hooks/useMenuEvents.ts | 4 +- src/hooks/useVaultLoader.test.ts | 19 +-- src/hooks/useVaultLoader.ts | 10 +- src/mock-tauri/mock-handlers.ts | 3 +- src/types.ts | 9 +- tests/smoke/git-divergence-conflicts.spec.ts | 90 +++++++++++ 26 files changed, 753 insertions(+), 60 deletions(-) create mode 100644 src/components/ConflictNoteBanner.test.tsx create mode 100644 src/components/ConflictNoteBanner.tsx create mode 100644 tests/smoke/git-divergence-conflicts.spec.ts 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') + }) +})