diff --git a/docs/assets/screenshots/windows-git-log-tool-window.png b/docs/assets/screenshots/windows-git-log-tool-window.png new file mode 100644 index 000000000..1159a12be Binary files /dev/null and b/docs/assets/screenshots/windows-git-log-tool-window.png differ diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index ea1e310dd..6551bebb2 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -148,6 +148,41 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { ); "git.checkoutPreflight" } + "git_merge" | "git_rebase" => { + let branch = take_text(&mut payload, "branchName")?; + payload.insert( + "reference".into(), + json!(local_branch_reference(&branch)), + ); + payload.insert( + "operation".into(), + json!(if command == "git_merge" { + "merge" + } else { + "rebase" + }), + ); + "git.write" + } + "git_integration_preflight" => { + let branch = take_text(&mut payload, "branchName")?; + payload.insert( + "reference".into(), + json!(local_branch_reference(&branch)), + ); + "git.integrationPreflight" + } + "git_operation_state" => "git.operationState", + "git_operation_continue" | "git_operation_abort" | "git_operation_skip" => { + let operation = match command { + "git_operation_continue" => "operationContinue", + "git_operation_abort" => "operationAbort", + _ => "operationSkip", + }; + payload.insert("operation".into(), json!(operation)); + "git.write" + } + "git_conflict_markers" => "git.conflictMarkers", "git_create_stash" => { payload.insert("operation".into(), json!("stashPush")); "git.write" @@ -500,6 +535,89 @@ mod tests { ); } + #[test] + fn translates_merge_and_rebase_to_qualified_references() { + let (merge_command, merge_payload) = translate( + "git_merge", + json!({ "repoPath": "C:/work", "branchName": "feature/demo" }), + ) + .unwrap(); + assert_eq!(merge_command, "git.write"); + assert_eq!( + merge_payload, + json!({ + "root": "C:/work", + "operation": "merge", + "reference": "refs/heads/feature/demo" + }) + ); + + let (rebase_command, rebase_payload) = translate( + "git_rebase", + json!({ "repoPath": "C:/work", "branchName": "main" }), + ) + .unwrap(); + assert_eq!(rebase_command, "git.write"); + assert_eq!( + rebase_payload, + json!({ + "root": "C:/work", + "operation": "rebase", + "reference": "refs/heads/main" + }) + ); + } + + #[test] + fn translates_integration_preflight_operation() { + let (command, payload) = translate( + "git_integration_preflight", + json!({ "repoPath": "C:/work", "branchName": "main", "operation": "rebase" }), + ) + .unwrap(); + + assert_eq!(command, "git.integrationPreflight"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "reference": "refs/heads/main", + "operation": "rebase" + }) + ); + } + + #[test] + fn translates_operation_state_and_resolution_commands() { + let (state_command, state_payload) = + translate("git_operation_state", json!({ "repoPath": "C:/work" })).unwrap(); + assert_eq!(state_command, "git.operationState"); + assert_eq!(state_payload, json!({ "root": "C:/work" })); + + for (compat, operation) in [ + ("git_operation_continue", "operationContinue"), + ("git_operation_abort", "operationAbort"), + ("git_operation_skip", "operationSkip"), + ] { + let (command, payload) = + translate(compat, json!({ "repoPath": "C:/work" })).unwrap(); + assert_eq!(command, "git.write"); + assert_eq!( + payload, + json!({ "root": "C:/work", "operation": operation }) + ); + } + } + + #[test] + fn translates_conflict_markers_request() { + let (command, payload) = + translate("git_conflict_markers", json!({ "repoPath": "C:/work" })).unwrap(); + + assert_eq!(command, "git.conflictMarkers"); + assert_eq!(payload, json!({ "root": "C:/work" })); + } + #[test] fn translates_delete_branch_to_qualified_reference() { let (command, payload) = translate( diff --git a/windows/tauri/src/features/command-palette/components/command-palette.tsx b/windows/tauri/src/features/command-palette/components/command-palette.tsx index e52686b33..4078e3993 100644 --- a/windows/tauri/src/features/command-palette/components/command-palette.tsx +++ b/windows/tauri/src/features/command-palette/components/command-palette.tsx @@ -312,6 +312,8 @@ const CommandPaletteContent = ({ commandPaletteInitialView }: CommandPaletteCont activeRepoPath, setIsSidebarVisible, setActiveView, + setIsBottomPaneVisible, + setBottomPaneActiveTab, showToast, gitOperations: { stageAllFiles, diff --git a/windows/tauri/src/features/command-palette/constants/git-actions.tsx b/windows/tauri/src/features/command-palette/constants/git-actions.tsx index eb3036e29..af519e92c 100644 --- a/windows/tauri/src/features/command-palette/constants/git-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/git-actions.tsx @@ -19,6 +19,8 @@ interface GitActionsParams { activeRepoPath?: string | null; setIsSidebarVisible: (v: boolean) => void; setActiveView: (view: "files" | "git" | "github-prs") => void; + setIsBottomPaneVisible: (visible: boolean) => void; + setBottomPaneActiveTab: (tab: "gitLog") => void; showToast: (params: { message: string; type: "success" | "error" | "info" }) => void; gitOperations: { stageAllFiles: (path: string) => Promise; @@ -38,6 +40,8 @@ export const createGitActions = (params: GitActionsParams): Action[] => { activeRepoPath, setIsSidebarVisible, setActiveView, + setIsBottomPaneVisible, + setBottomPaneActiveTab, showToast, gitOperations, onClose, @@ -155,6 +159,18 @@ export const createGitActions = (params: GitActionsParams): Action[] => { category: "Git", action: () => openGitAction({ type: "show-tab", tab: "history" }), }, + { + id: "git-open-log", + label: "Git: Open Log", + description: "Open the full Git Log tool window", + icon: , + category: "Git", + action: () => { + setBottomPaneActiveTab("gitLog"); + setIsBottomPaneVisible(true); + onClose(); + }, + }, { id: "git-manage-remotes", label: "Git: Manage Remotes", diff --git a/windows/tauri/src/features/git/api/git-commits-api.ts b/windows/tauri/src/features/git/api/git-commits-api.ts index 29c565dc9..b9d3f3982 100644 --- a/windows/tauri/src/features/git/api/git-commits-api.ts +++ b/windows/tauri/src/features/git/api/git-commits-api.ts @@ -1,5 +1,5 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; -import type { GitCommit, GitHistorySnapshot } from "../types/git.types"; +import type { GitCommit, GitCommitFile, GitHistorySnapshot } from "../types/git.types"; import { emitGitChanged } from "../events/git-events"; import { runGitRead } from "../runtime/git-read-coordinator"; import { @@ -27,6 +27,7 @@ export const commitChanges = async (repoPath: string, message: string): Promise< export const getGitHistory = async ( repoPath: string, limit = 50, + reference?: string, ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPath(repoPath); @@ -34,10 +35,11 @@ export const getGitHistory = async ( return null; } - return await runGitRead(resolvedRepoPath, `log:${limit}`, () => + return await runGitRead(resolvedRepoPath, `log:${reference ?? "all"}:${limit}`, () => tauriInvoke("git_log", { repoPath: resolvedRepoPath, limit, + ...(reference ? { reference } : {}), }), ); } catch (error) { @@ -50,3 +52,26 @@ export const getGitHistory = async ( export const getGitLog = async (repoPath: string, limit = 50): Promise => (await getGitHistory(repoPath, limit))?.commits ?? []; + +export const getCommitFiles = async ( + repoPath: string, + commitHash: string, +): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPath(repoPath); + if (!resolvedRepoPath) return null; + + const result = await runGitRead(resolvedRepoPath, `commit-files:${commitHash}`, () => + tauriInvoke<{ files: GitCommitFile[] }>("git.commitFiles", { + repoPath: resolvedRepoPath, + commit: commitHash, + }), + ); + return result.files ?? []; + } catch (error) { + if (!isNotGitRepositoryError(error)) { + console.error("Failed to get files for commit:", error); + } + return null; + } +}; diff --git a/windows/tauri/src/features/git/api/git-integration-api.test.ts b/windows/tauri/src/features/git/api/git-integration-api.test.ts new file mode 100644 index 000000000..6731ea69b --- /dev/null +++ b/windows/tauri/src/features/git/api/git-integration-api.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { GitOperationState } from "../types/git.types"; + +const invoke = mock(async (_command: string, _args?: unknown): Promise => null); +const emitGitChanged = mock((_change: unknown) => {}); +const resolveRepositoryPathOrThrow = mock(async (repoPath: string) => repoPath); + +mock.module("@/platform/tauri-core", () => ({ invoke })); +mock.module("../events/git-events", () => ({ emitGitChanged })); +mock.module("./git-repo-api", () => ({ resolveRepositoryPathOrThrow })); + +const { getConflictMarkerPaths, getOperationState, mergeBranch, rebaseOntoBranch } = await import( + "./git-integration-api" +); + +const operationState = ( + kind: GitOperationState["kind"], + conflictedPaths: string[] = [], +): GitOperationState => ({ + kind, + reference: null, + conflictedPaths, + step: null, + total: null, +}); + +beforeEach(() => { + invoke.mockReset(); + emitGitChanged.mockReset(); + resolveRepositoryPathOrThrow.mockReset(); + resolveRepositoryPathOrThrow.mockImplementation(async (repoPath: string) => repoPath); +}); + +describe("Git integration state", () => { + test("reports a stopped rebase even when no conflicted paths remain", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "git_integration_preflight") { + return { blockingPaths: [], blocksEntirely: false }; + } + if (command === "git_rebase") throw new Error("rebase stopped"); + if (command === "git_operation_state") return operationState("rebase"); + return null; + }); + + await expect(rebaseOntoBranch("C:/repo", "main")).resolves.toEqual({ status: "stopped" }); + expect(emitGitChanged).toHaveBeenCalledWith({ + repoPath: "C:/repo", + scopes: ["working-tree", "history", "refs"], + source: "rebase-rejected", + }); + }); + + test("reports conflicted paths when a merge stops on conflicts", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "git_integration_preflight") { + return { blockingPaths: [], blocksEntirely: false }; + } + if (command === "git_merge") throw new Error("merge stopped"); + if (command === "git_operation_state") { + return operationState("merge", ["src/app.ts"]); + } + return null; + }); + + await expect(mergeBranch("C:/repo", "feature")).resolves.toEqual({ + status: "conflicts", + conflictedPaths: ["src/app.ts"], + }); + }); + + test("propagates operation state query failures", async () => { + invoke.mockRejectedValue(new Error("Core unavailable")); + + await expect(getOperationState("C:/repo")).rejects.toThrow("Core unavailable"); + }); + + test("propagates conflict marker query failures so commits fail closed", async () => { + invoke.mockRejectedValue(new Error("Core unavailable")); + + await expect(getConflictMarkerPaths("C:/repo")).rejects.toThrow("Core unavailable"); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-integration-api.ts b/windows/tauri/src/features/git/api/git-integration-api.ts new file mode 100644 index 000000000..2fea725ec --- /dev/null +++ b/windows/tauri/src/features/git/api/git-integration-api.ts @@ -0,0 +1,142 @@ +import { invoke as tauriInvoke } from "@/platform/tauri-core"; +import { emitGitChanged } from "../events/git-events"; +import { resolveRepositoryPathOrThrow } from "./git-repo-api"; +import type { GitOperationState } from "../types/git.types"; + +type IntegrationOperation = "merge" | "rebase"; + +interface IntegrationPreflightResult { + blockingPaths: string[]; + blocksEntirely: boolean; +} + +export type IntegrationOutcome = + | { status: "clean" } + | { status: "conflicts"; conflictedPaths: string[] } + | { status: "stopped" } + | { status: "blocked"; blockingPaths: string[]; blocksEntirely: boolean } + | { status: "error"; message: string }; + +export interface OperationResolution { + ok: boolean; + message: string; +} + +const errorMessage = (error: unknown): string => { + const message = error instanceof Error ? error.message : String(error); + return message.trim() || "Git operation failed"; +}; + +const notifyOperationChanged = (repoPath: string, source: string) => { + emitGitChanged({ + repoPath, + scopes: ["working-tree", "history", "refs"], + source, + }); +}; + +export const getOperationState = async ( + repoPath: string, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + return tauriInvoke("git_operation_state", { + repoPath: resolvedRepoPath, + }); +}; + +export const getConflictMarkerPaths = async (repoPath: string): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + const result = await tauriInvoke<{ paths: string[] }>("git_conflict_markers", { + repoPath: resolvedRepoPath, + }); + return result.paths; +}; + +const runIntegration = async ( + repoPath: string, + branchName: string, + operation: IntegrationOperation, +): Promise => { + const command = operation === "merge" ? "git_merge" : "git_rebase"; + try { + await tauriInvoke(command, { repoPath, branchName }); + notifyOperationChanged(repoPath, `${operation}-completed`); + return { status: "clean" }; + } catch (error) { + // A conflict stop exits non-zero like a real failure; the authoritative + // distinction is whether Git left an operation state behind. + notifyOperationChanged(repoPath, `${operation}-rejected`); + try { + const state = await getOperationState(repoPath); + if (state?.kind === operation) { + if (state.conflictedPaths.length > 0) { + return { status: "conflicts", conflictedPaths: state.conflictedPaths }; + } + return { status: "stopped" }; + } + } catch (stateError) { + console.error(`Failed to read ${operation} state after Git rejected the operation:`, stateError); + } + return { status: "error", message: errorMessage(error) }; + } +}; + +const startIntegration = async ( + repoPath: string, + branchName: string, + operation: IntegrationOperation, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + + let preflight: IntegrationPreflightResult | null = null; + try { + preflight = await tauriInvoke( + "git_integration_preflight", + { repoPath: resolvedRepoPath, branchName, operation }, + ); + } catch { + // A failed preflight must not block the operation itself; Git will still + // refuse safely when the tree is dirty. + } + + if (preflight && preflight.blockingPaths.length > 0) { + return { + status: "blocked", + blockingPaths: preflight.blockingPaths, + blocksEntirely: preflight.blocksEntirely, + }; + } + + return runIntegration(resolvedRepoPath, branchName, operation); +}; + +export const mergeBranch = (repoPath: string, branchName: string) => + startIntegration(repoPath, branchName, "merge"); + +export const rebaseOntoBranch = (repoPath: string, branchName: string) => + startIntegration(repoPath, branchName, "rebase"); + +const resolveOperation = async ( + repoPath: string, + action: "git_operation_continue" | "git_operation_abort" | "git_operation_skip", +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + try { + await tauriInvoke(action, { repoPath: resolvedRepoPath }); + notifyOperationChanged(resolvedRepoPath, "operation-resolved"); + return { ok: true, message: "Git operation finished" }; + } catch (error) { + // Even a rejected continue can change repository state, so refresh anyway. + notifyOperationChanged(resolvedRepoPath, "operation-resolution-rejected"); + return { ok: false, message: errorMessage(error) }; + } +}; + +export const continueOperation = (repoPath: string) => + resolveOperation(repoPath, "git_operation_continue"); + +export const abortOperation = (repoPath: string) => + resolveOperation(repoPath, "git_operation_abort"); + +export const skipOperationStep = (repoPath: string) => + resolveOperation(repoPath, "git_operation_skip"); diff --git a/windows/tauri/src/features/git/components/git-branch-manager.tsx b/windows/tauri/src/features/git/components/git-branch-manager.tsx index fcc2b813e..dd8e4d5c7 100644 --- a/windows/tauri/src/features/git/components/git-branch-manager.tsx +++ b/windows/tauri/src/features/git/components/git-branch-manager.tsx @@ -19,12 +19,20 @@ import { CommandTabs, useCommandListNavigation, } from "@/ui/command"; -import { GitBranchIcon, FolderOpenIcon, NodesIcon } from "@/ui/icons"; +import { GitBranchIcon, FolderOpenIcon, GitMergeIcon, NodesIcon, DotsThreeIcon } from "@/ui/icons"; import { showConfirmDialog } from "@/ui/dialog"; import { cn } from "@/utils/cn"; import { getFolderName, getRelativePath } from "@/utils/path-helpers"; import { matchesSearchQuery } from "@/utils/search-match"; import { checkoutBranch, createBranch, deleteBranch, getBranches } from "../api/git-branches-api"; +import { mergeBranch, rebaseOntoBranch, type IntegrationOutcome } from "../api/git-integration-api"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/ui/dropdown"; import { resolveRepositoryPath } from "../api/git-repo-api"; import { createStash } from "../api/git-stash-api"; import { addWorktree, getWorktrees } from "../api/git-worktrees-api"; @@ -343,6 +351,76 @@ const GitBranchManager = ({ } }; + const reportIntegrationOutcome = (branchName: string, outcome: IntegrationOutcome) => { + if (outcome.status === "clean") { + showToast({ message: `Merged ${branchName} successfully.`, type: "success" }); + } else if (outcome.status === "conflicts") { + showToast({ + message: `Stopped with ${outcome.conflictedPaths.length} conflicted file${ + outcome.conflictedPaths.length === 1 ? "" : "s" + }. Resolve them in the changes list, then continue.`, + type: "warning", + duration: 6000, + }); + } else if (outcome.status === "stopped") { + showToast({ + message: `${branchName} stopped before completion. Continue, skip, or abort from the changes list.`, + type: "warning", + duration: 6000, + }); + } else if (outcome.status === "blocked") { + const listed = outcome.blockingPaths.slice(0, 3).join(", "); + const remaining = outcome.blockingPaths.length - Math.min(outcome.blockingPaths.length, 3); + showToast({ + message: `Uncommitted changes would be overwritten: ${listed}${ + remaining > 0 ? ` (+${remaining} more)` + : ""}. Stash or commit them first.`, + type: "warning", + duration: 6000, + }); + } else { + showToast({ message: outcome.message, type: "error" }); + } + }; + + const handleIntegration = async (branchName: string, operation: "merge" | "rebase") => { + if (!repoPath || !branchName || branchName === currentBranch) return; + + const action = operation === "merge" ? "Merge" : "Rebase"; + const message = + operation === "merge" + ? `Merge branch "${branchName}" into "${currentBranch}"? Conflicts may require resolution.` + : `Rebase "${currentBranch}" onto "${branchName}"? Conflicts may require resolution.`; + const confirmed = await showConfirmDialog(message, { + title: `${action} Branch`, + confirmLabel: action, + }); + if (!confirmed) return; + + setIsLoading(true); + try { + const outcome = + operation === "merge" + ? await mergeBranch(repoPath, branchName) + : await rebaseOntoBranch(repoPath, branchName); + reportIntegrationOutcome(branchName, outcome); + if ( + outcome.status === "clean" || + outcome.status === "conflicts" || + outcome.status === "stopped" + ) { + onBranchChange?.(); + } + } catch (error) { + showToast({ + message: error instanceof Error ? error.message : `${action} failed.`, + type: "error", + }); + } finally { + setIsLoading(false); + } + }; + const handleCreateBranch = async (branchName: string) => { if (!repoPath || !branchName.trim()) return; @@ -623,6 +701,8 @@ const GitBranchManager = ({ onMouseEnter={() => setSelectedIndex(index + (createBranchName ? 1 : 0))} onSelect={() => void handleBranchChange(branch)} onDelete={() => void handleDeleteBranch(branch)} + onMerge={() => void handleIntegration(branch, "merge")} + onRebase={() => void handleIntegration(branch, "rebase")} /> ))} @@ -772,6 +852,8 @@ function BranchRow({ onMouseEnter, onSelect, onDelete, + onMerge, + onRebase, }: { branch: string; isCurrent: boolean; @@ -780,6 +862,8 @@ function BranchRow({ onMouseEnter: () => void; onSelect: () => void; onDelete: () => void; + onMerge: () => void; + onRebase: () => void; }) { return ( current : null} action={ !isCurrent ? ( - + + + } + > + + + + + + Merge into Current Branch + + + + Rebase Current Branch onto This + + + + + Delete Branch + + + + ) : null } /> diff --git a/windows/tauri/src/features/git/components/git-commit-panel.tsx b/windows/tauri/src/features/git/components/git-commit-panel.tsx index 2a4b51189..a080e6bce 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.tsx @@ -24,8 +24,10 @@ import { } from "@/features/editor/services/editor-inline-edit-service"; import { getFileDiff } from "../api/git-diff-api"; import { commitChanges, getGitLog } from "../api/git-commits-api"; +import { getConflictMarkerPaths } from "../api/git-integration-api"; import { pullChanges, pushChanges, type GitRemoteActionResult } from "../api/git-remotes-api"; import { useGitBlameStore } from "../stores/git-blame.store"; +import { useGitStore } from "../stores/git.store"; import type { GitDiff, GitFile } from "../types/git.types"; interface GitCommitPanelProps { @@ -287,6 +289,27 @@ const GitCommitPanel = ({ const handleCommit = async () => { if (!repoPath || !commitMessage.trim() || stagedFilesCount === 0) return; + // A conflicted merge/rebase must be resolved before the merge commit can + // be finalized; guard here so Git's raw refusal never reaches the user. + const conflictedPaths = useGitStore.getState().operationState?.conflictedPaths ?? []; + if (conflictedPaths.length > 0) { + setError(`Resolve the conflicts first: ${conflictedPaths.join(", ")}`); + return; + } + + let markerPaths: string[]; + try { + markerPaths = await getConflictMarkerPaths(repoPath); + } catch (markerError) { + console.error("Failed to check staged files for conflict markers:", markerError); + setError("Unable to verify conflict markers. Retry before committing."); + return; + } + if (markerPaths.length > 0) { + setError(`Conflict markers remain in: ${markerPaths.join(", ")}`); + return; + } + setIsCommitting(true); setError(null); diff --git a/windows/tauri/src/features/git/components/git-operation-banner.tsx b/windows/tauri/src/features/git/components/git-operation-banner.tsx new file mode 100644 index 000000000..96a0153e7 --- /dev/null +++ b/windows/tauri/src/features/git/components/git-operation-banner.tsx @@ -0,0 +1,132 @@ +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/ui/button"; +import { WarningIcon } from "@/ui/icons"; +import { + abortOperation, + continueOperation, + skipOperationStep, + type OperationResolution, +} from "../api/git-integration-api"; +import { useGitStore } from "../stores/git.store"; +import { cn } from "@/utils/cn"; +import type { GitOperationKind } from "../types/git.types"; + +const IN_PROGRESS_TITLES: Record = { + merge: "Merge in progress", + rebase: "Rebase in progress", + cherryPick: "Cherry-pick in progress", + revert: "Revert in progress", +}; + +const CONTINUE_TITLES: Record = { + merge: "Continue Merge", + rebase: "Continue Rebase", + cherryPick: "Continue Cherry-pick", + revert: "Continue Revert", +}; + +interface GitOperationBannerProps { + repoPath: string; +} + +/** + * Pins the state of an in-progress merge/rebase/cherry-pick/revert above the + * changes list. Deliberately not a dialog: the controls must stay reachable + * while the user edits conflicted files, mirroring the macOS sidebar banner. + */ +const GitOperationBanner = ({ repoPath }: GitOperationBannerProps) => { + const operation = useGitStore((state) => state.operationState); + + const [isResolving, setIsResolving] = useState(false); + + const resolve = useCallback( + async (action: (repoPath: string) => Promise) => { + setIsResolving(true); + try { + const result = await action(repoPath); + // A rejected continue can still change state; the API already + // requested a refresh either way, so only surface the message here. + if (!result.ok) { + toast.error(result.message); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Git operation failed"); + } finally { + setIsResolving(false); + } + }, + [repoPath, toast], + ); + + if (!operation) { + return null; + } + + const hasConflicts = operation.conflictedPaths.length > 0; + const hasProgress = operation.step != null && operation.total != null; + + return ( +
+
+ + {IN_PROGRESS_TITLES[operation.kind]} + {operation.reference ? ( + + — {operation.reference.slice(0, 7)} + + ) : null} +
+ +
+ {hasProgress ? Step {operation.step} of {operation.total}. : null} + {hasConflicts ? ( + + Resolve {operation.conflictedPaths.length} conflicted file + {operation.conflictedPaths.length === 1 ? "" : "s"}, stage them, then continue. + + ) : ( + All conflicts resolved. Continue to finish, or abort to undo. + )} +
+ +
+ + {operation.kind === "rebase" ? ( + + ) : null} + +
+
+ ); +}; + +export default GitOperationBanner; diff --git a/windows/tauri/src/features/git/components/git-view.tsx b/windows/tauri/src/features/git/components/git-view.tsx index d70012b27..c8993cbf0 100644 --- a/windows/tauri/src/features/git/components/git-view.tsx +++ b/windows/tauri/src/features/git/components/git-view.tsx @@ -57,6 +57,7 @@ import GitCommitPanel from "./git-commit-panel"; import GitCommandSurface from "./git-command-surface"; import GitRemoteManager from "./git-remote-manager"; import GitTagManager from "./git-tag-manager"; +import GitOperationBanner from "./git-operation-banner"; import GitStatusPanel from "./status/git-status-panel"; interface GitViewProps { @@ -834,21 +835,24 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { { id: "changes", content: ( - void handleViewWorkingTreeDiff(scope)} - onShowCommitDiffPicker={handleShowCommitDiffList} - onShowBranchDiffPicker={() => void handleShowBranchDiffList()} - onShowStashDiffPicker={() => { - setShowStashList(true); - setStashSearchQuery(""); - }} - onRefresh={refreshAfterAction} - repoPath={activeRepoPath} - /> +
+ + void handleViewWorkingTreeDiff(scope)} + onShowCommitDiffPicker={handleShowCommitDiffList} + onShowBranchDiffPicker={() => void handleShowBranchDiffList()} + onShowStashDiffPicker={() => { + setShowStashList(true); + setStashSearchQuery(""); + }} + onRefresh={refreshAfterAction} + repoPath={activeRepoPath} + /> +
), }, { diff --git a/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx b/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx new file mode 100644 index 000000000..3213138f4 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-commit-file-tree.tsx @@ -0,0 +1,173 @@ +import { CaretDownIcon, CaretRightIcon, FileIcon, FolderIcon } from "@/ui/icons"; +import { useMemo, useState } from "react"; +import { cn } from "@/utils/cn"; +import type { GitCommitFile } from "../../types/git.types"; + +interface FileTreeNode { + id: string; + name: string; + path: string; + file?: GitCommitFile; + children: FileTreeNode[]; +} + +interface MutableFileTreeNode extends FileTreeNode { + children: MutableFileTreeNode[]; +} + +function buildFileTree(files: GitCommitFile[]): FileTreeNode[] { + const roots: MutableFileTreeNode[] = []; + for (const file of files) { + const parts = file.path.split("/").filter(Boolean); + let children = roots; + let path = ""; + parts.forEach((name, index) => { + path = path ? `${path}/${name}` : name; + let node = children.find((candidate) => candidate.name === name); + if (!node) { + node = { id: path, name, path, children: [] }; + children.push(node); + } + if (index === parts.length - 1) node.file = file; + children = node.children; + }); + } + const sort = (nodes: MutableFileTreeNode[]) => { + nodes.sort((left, right) => { + if (Boolean(left.children.length) !== Boolean(right.children.length)) { + return left.children.length ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); + nodes.forEach((node) => sort(node.children)); + }; + sort(roots); + return roots; +} + +function statusClassName(status: string) { + if (status.startsWith("A")) return "text-emerald-400"; + if (status.startsWith("D")) return "text-red-400"; + if (status.startsWith("R")) return "text-amber-400"; + return "text-sky-400"; +} + +function FileNode({ + node, + depth, + collapsed, + selectedPath, + onToggle, + onSelect, + onOpen, +}: { + node: FileTreeNode; + depth: number; + collapsed: Set; + selectedPath: string | null; + onToggle: (path: string) => void; + onSelect: (path: string) => void; + onOpen: (path: string) => void; +}) { + const isDirectory = node.children.length > 0 && !node.file; + const isCollapsed = collapsed.has(node.path); + + return ( + <> + + {!isCollapsed && + node.children.map((child) => ( + + ))} + + ); +} + +export function GitCommitFileTree({ + files, + selectedPath, + onSelect, + onOpen, +}: { + files: GitCommitFile[]; + selectedPath: string | null; + onSelect: (path: string) => void; + onOpen: (path: string) => void; +}) { + const tree = useMemo(() => buildFileTree(files), [files]); + const [collapsed, setCollapsed] = useState>(new Set()); + const toggle = (path: string) => { + setCollapsed((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + return ( +
+ {tree.map((node) => ( + + ))} +
+ ); +} diff --git a/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx b/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx new file mode 100644 index 000000000..35a2a728b --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-commit-inspector.tsx @@ -0,0 +1,140 @@ +import { useEffect, useRef, useState } from "react"; +import { GitDiffIcon } from "@/ui/icons"; +import { Button } from "@/ui/button"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui/resizable"; +import { getCommitFiles } from "../../api/git-commits-api"; +import { useGitLogPreferencesStore } from "../../stores/git-log-preferences.store"; +import type { GitCommit, GitCommitFile } from "../../types/git.types"; +import { GitCommitFileTree } from "./git-commit-file-tree"; + +type FilesLoadState = "idle" | "loading" | "ready" | "failed"; + +export function GitCommitInspector({ + repoPath, + commit, + onOpenDiff, +}: { + repoPath: string | null; + commit: GitCommit | null; + onOpenDiff: (commit: GitCommit, filePath?: string) => void; +}) { + const [files, setFiles] = useState([]); + const [loadState, setLoadState] = useState("idle"); + const [selectedPath, setSelectedPath] = useState(null); + const requestIdRef = useRef(0); + const inspectorPanelLayout = useGitLogPreferencesStore.use.inspectorPanelLayout(); + const { setInspectorPanelLayout } = useGitLogPreferencesStore.use.actions(); + + useEffect(() => { + const requestId = ++requestIdRef.current; + setFiles([]); + setSelectedPath(null); + if (!repoPath || !commit) { + setLoadState("idle"); + return; + } + + setLoadState("loading"); + void getCommitFiles(repoPath, commit.hash).then((result) => { + if (requestId !== requestIdRef.current) return; + if (!result) { + setLoadState("failed"); + return; + } + setFiles(result); + setLoadState("ready"); + }); + + return () => { + requestIdRef.current += 1; + }; + }, [commit, repoPath]); + + return ( +
+ { + if (meta.isUserInteraction) setInspectorPanelLayout(layout); + }} + > + +
+
+ Commit files + + {loadState === "loading" ? "Loading…" : `${files.length} files`} + + +
+ {!commit ? ( +
+ Select a commit +
+ ) : loadState === "loading" ? ( +
+ Loading changed files… +
+ ) : loadState === "failed" ? ( +
+ Unable to load changed files +
+ ) : files.length === 0 ? ( +
+ No changed files +
+ ) : ( + onOpenDiff(commit, path)} + /> + )} +
+
+ + +
+ {commit ? ( +
+
{commit.message}
+ {commit.description ? ( +
+ {commit.description} +
+ ) : null} +
+ {commit.shortHash} · {commit.author} + {commit.email ? ` <${commit.email}>` : ""} +
+
{commit.date}
+ {commit.decorations ? ( +
{commit.decorations}
+ ) : null} +
+ {commit.hash} +
+
+ ) : ( +
+ Commit details +
+ )} +
+
+
+
+ ); +} diff --git a/windows/tauri/src/features/git/components/log/git-commit-table.tsx b/windows/tauri/src/features/git/components/log/git-commit-table.tsx new file mode 100644 index 000000000..4681f96df --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-commit-table.tsx @@ -0,0 +1,258 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useEffect, useMemo, useRef } from "react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, +} from "@/ui/context-menu"; +import { + CopyIcon as Copy, + EyeIcon as Eye, + EyeSlashIcon as EyeSlash, + GitBranchIcon as GitBranch, + GitDiffIcon as GitDiff, + MagnifyingGlassIcon as Search, + XIcon, +} from "@/ui/icons"; +import { Button } from "@/ui/button"; +import { cn } from "@/utils/cn"; +import { + type GitLogFilterScope, + useGitLogPreferencesStore, +} from "../../stores/git-log-preferences.store"; +import type { GitCommit } from "../../types/git.types"; +import { layoutGitGraph } from "../../utils/git-graph-layout"; +import { matchesGitLogCommit } from "../../utils/git-log-filter"; +import { GitGraphRow } from "./git-graph-row"; + +const ROW_HEIGHT = 30; + +export function GitCommitTable({ + commits, + selectedCommit, + hasMore, + isLoadingMore, + onSelect, + onOpenDiff, + onCompareWithHead, + onCopyHash, + onCopyMessage, + onLoadMore, +}: { + commits: GitCommit[]; + selectedCommit: GitCommit | null; + hasMore: boolean; + isLoadingMore: boolean; + onSelect: (commit: GitCommit) => void; + onOpenDiff: (commit: GitCommit) => void; + onCompareWithHead: (commit: GitCommit) => void; + onCopyHash: (commit: GitCommit) => void; + onCopyMessage: (commit: GitCommit) => void; + onLoadMore: () => void; +}) { + const query = useGitLogPreferencesStore.use.filterQuery(); + const scope = useGitLogPreferencesStore.use.filterScope(); + const showDecorations = useGitLogPreferencesStore.use.showDecorations(); + const { setFilterQuery, setFilterScope, setShowDecorations } = + useGitLogPreferencesStore.use.actions(); + const scrollRef = useRef(null); + const layout = useMemo(() => layoutGitGraph(commits), [commits]); + const visibleRows = useMemo( + () => layout.rows.filter((row) => matchesGitLogCommit(row.commit, query, scope)), + [layout.rows, query, scope], + ); + const virtualizer = useVirtualizer({ + count: visibleRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 14, + }); + + useEffect(() => { + if (!selectedCommit) return; + const selectedIndex = visibleRows.findIndex((row) => row.commit.hash === selectedCommit.hash); + if (selectedIndex >= 0) virtualizer.scrollToIndex(selectedIndex, { align: "auto" }); + }, [selectedCommit, virtualizer, visibleRows]); + + const selectRowAt = (index: number) => { + const nextIndex = Math.max(0, Math.min(index, visibleRows.length - 1)); + const nextCommit = visibleRows[nextIndex]?.commit; + if (!nextCommit) return; + onSelect(nextCommit); + virtualizer.scrollToIndex(nextIndex, { align: "auto" }); + globalThis.requestAnimationFrame?.(() => { + scrollRef.current + ?.querySelector(`[data-git-commit-index="${nextIndex}"]`) + ?.focus(); + }); + }; + + const handleRowKeyDown = (event: React.KeyboardEvent, commit: GitCommit) => { + const currentIndex = visibleRows.findIndex((row) => row.commit.hash === commit.hash); + if (currentIndex < 0) return; + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + selectRowAt(currentIndex + 1); + break; + case "ArrowUp": + event.preventDefault(); + selectRowAt(currentIndex - 1); + break; + case "Home": + event.preventDefault(); + selectRowAt(0); + break; + case "End": + event.preventDefault(); + selectRowAt(visibleRows.length - 1); + break; + case "Enter": + event.preventDefault(); + onOpenDiff(commit); + break; + } + }; + + return ( +
+
+
+ + setFilterQuery(event.target.value)} + className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-subtle-foreground" + placeholder={`${scope[0].toUpperCase()}${scope.slice(1)} filter`} + aria-label="Filter Git log" + /> + {query ? ( + + ) : null} +
+ + + + {visibleRows.length}/{commits.length} + +
+ +
+ Commit + Author + Date +
+ +
+ {visibleRows.length === 0 ? ( +
+ {query ? "No commits match this filter" : "No commits in this view"} +
+ ) : ( + <> +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = visibleRows[virtualRow.index]; + const isSelected = selectedCommit?.hash === row.commit.hash; + return ( + + onSelect(row.commit)} + onDoubleClick={() => onOpenDiff(row.commit)} + onContextMenu={() => onSelect(row.commit)} + onKeyDown={(event) => handleRowKeyDown(event, row.commit)} + title="Double-click or press Enter to open commit diff" + > + + + {row.commit.author} + + + {row.commit.date} + + + + onOpenDiff(row.commit)}> + + Open Commit Diff + Enter + + onCompareWithHead(row.commit)}> + + Compare with HEAD + + + onCopyHash(row.commit)}> + + Copy Commit Hash + + onCopyMessage(row.commit)}> + + Copy Commit Message + + + + ); + })} +
+ {hasMore ? ( +
+ +
+ ) : null} + + )} +
+
+ ); +} diff --git a/windows/tauri/src/features/git/components/log/git-graph-row.tsx b/windows/tauri/src/features/git/components/log/git-graph-row.tsx new file mode 100644 index 000000000..58543276c --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-graph-row.tsx @@ -0,0 +1,101 @@ +import { cn } from "@/utils/cn"; +import type { GitGraphLabel, GitGraphRow as GraphRow } from "../../utils/git-graph-layout"; + +const ROW_HEIGHT = 30; +const LANE_GAP = 13; +const GRAPH_PADDING = 8; +const GRAPH_COLORS = ["#55d68b", "#65a9ff", "#d77eea", "#f3aa59", "#e76c72", "#56c7cf"]; + +function graphColor(index: number) { + return GRAPH_COLORS[index % GRAPH_COLORS.length]; +} + +function labelClassName(label: GitGraphLabel) { + switch (label.kind) { + case "head": + return "border-sky-500/40 bg-sky-500/18 text-sky-300"; + case "remote": + return "border-indigo-500/40 bg-indigo-500/18 text-indigo-300"; + case "tag": + return "border-amber-500/40 bg-amber-500/18 text-amber-300"; + default: + return "border-emerald-500/40 bg-emerald-500/18 text-emerald-300"; + } +} + +export function GitGraphRow({ row, showDecorations }: { row: GraphRow; showDecorations: boolean }) { + const width = Math.max(30, row.laneCount * LANE_GAP + GRAPH_PADDING * 2); + const nodeX = GRAPH_PADDING + row.lane * LANE_GAP; + const middleY = ROW_HEIGHT / 2; + + return ( + <> + + +
+ {showDecorations && + row.labels.map((label, index) => ( + + {label.title} + + ))} + + {row.commit.message} + +
+ + ); +} diff --git a/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx b/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx new file mode 100644 index 000000000..c40690c2c --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-log-title-bar.tsx @@ -0,0 +1,107 @@ +import { + ArrowClockwiseIcon as Refresh, + CopyIcon as Copy, + GitBranchIcon, + GitDiffIcon as GitDiff, + MinusIcon, +} from "@/ui/icons"; +import { Button } from "@/ui/button"; + +export function GitLogTitleBar({ + referenceName, + isRefreshing, + isOpeningDiff, + isComparing, + hasSelectedCommit, + canCompareWithHead, + onShowAll, + onRefresh, + onOpenDiff, + onCompareWithHead, + onCopyHash, + onClose, +}: { + referenceName: string; + isRefreshing: boolean; + isOpeningDiff: boolean; + isComparing: boolean; + hasSelectedCommit: boolean; + canCompareWithHead: boolean; + onShowAll: () => void; + onRefresh: () => void; + onOpenDiff: () => void; + onCompareWithHead: () => void; + onCopyHash: () => void; + onClose: () => void; +}) { + return ( +
+
+ + Git + + + Read-only + +
+
+ + + +
+
+ ); +} diff --git a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx new file mode 100644 index 000000000..87fcbc8f9 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/ui/button"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui/resizable"; +import { tryWriteClipboardText } from "@/utils/clipboard"; +import { useProjectStore } from "@/features/window/stores/project.store"; +import { useUIState } from "@/features/window/stores/ui-state.store"; +import { useGitLogController } from "../../hooks/use-git-log-controller"; +import { useGitDiffActions } from "../../hooks/use-git-diff-actions"; +import { useGitLogPreferencesStore } from "../../stores/git-log-preferences.store"; +import { useRepositoryStore } from "../../stores/git-repository.store"; +import type { GitCommit, GitFile } from "../../types/git.types"; +import type { + WorkingTreeDiffEntry, + WorkingTreeDiffScope, +} from "../../services/working-tree-diff-loader"; +import { GitCommitInspector } from "./git-commit-inspector"; +import { GitCommitTable } from "./git-commit-table"; +import { GitLogTitleBar } from "./git-log-title-bar"; +import { GitReferenceTree } from "./git-reference-tree"; + +export function GitLogToolWindow() { + const activeRepoPath = useRepositoryStore.use.activeRepoPath(); + const rootFolderPath = useProjectStore((state) => state.rootFolderPath); + const repoPath = activeRepoPath ?? rootFolderPath ?? null; + const setIsBottomPaneVisible = useUIState((state) => state.setIsBottomPaneVisible); + const { + history, + loadState, + error, + selectedReference, + isLoadingMore, + selectReference, + refresh, + loadMore, + } = useGitLogController(repoPath); + const [selectedCommit, setSelectedCommit] = useState(null); + const mainPanelLayout = useGitLogPreferencesStore.use.mainPanelLayout(); + const { setMainPanelLayout } = useGitLogPreferencesStore.use.actions(); + const currentReference = useMemo( + () => history.references.find((reference) => reference.isCurrent) ?? null, + [history.references], + ); + const commitByHash = useMemo( + () => new Map(history.commits.map((commit) => [commit.hash, commit] as const)), + [history.commits], + ); + const emptyWorkingTreeEntries = useMemo>( + () => ({ + all: [], + staged: [], + unstaged: [], + }), + [], + ); + const emptyGitFileByPath = useMemo(() => new Map(), []); + const { isLoadingCommitDiff, isLoadingBranchDiff, viewCommitDiff, viewBranchDiff } = + useGitDiffActions({ + activeRepoPath: repoPath, + gitFileByPath: emptyGitFileByPath, + workingTreeDiffEntriesByScope: emptyWorkingTreeEntries, + commitByHash, + currentBranch: currentReference?.shortName, + }); + + useEffect(() => { + setSelectedCommit((current) => { + if (current && commitByHash.has(current.hash)) return commitByHash.get(current.hash) ?? null; + return history.commits[0] ?? null; + }); + }, [commitByHash, history.commits]); + + const openDiff = (commit: GitCommit, filePath?: string) => { + if (isLoadingCommitDiff) return; + void viewCommitDiff(commit.hash, filePath); + }; + + const copyCommitText = async (text: string, label: string) => { + if (await tryWriteClipboardText(text)) { + toast.success(`${label} copied`); + return; + } + toast.error(`Unable to copy ${label.toLocaleLowerCase()}`); + }; + + const comparisonBaseRef = + selectedReference && !selectedReference.isCurrent + ? selectedReference.fullName + : selectedCommit?.hash; + + return ( +
+ { + setSelectedCommit(null); + selectReference(null); + }} + onRefresh={() => void refresh()} + onOpenDiff={() => { + if (selectedCommit) openDiff(selectedCommit); + }} + onCompareWithHead={() => { + if (comparisonBaseRef) void viewBranchDiff(comparisonBaseRef); + }} + onCopyHash={() => { + if (selectedCommit) void copyCommitText(selectedCommit.hash, "Commit hash"); + }} + onClose={() => setIsBottomPaneVisible(false)} + /> + + {loadState === "failed" && history.commits.length > 0 ? ( +
+ {error ?? "Unable to refresh Git log."} + +
+ ) : null} + + {!repoPath ? ( +
+
No repository open
+
Open a Git workspace to view its log.
+
+ ) : loadState === "loading" && history.commits.length === 0 ? ( +
+ Loading Git log… +
+ ) : loadState === "failed" && history.commits.length === 0 ? ( +
+
{error ?? "Unable to load Git log."}
+ +
+ ) : ( + { + if (meta.isUserInteraction) setMainPanelLayout(layout); + }} + > + + { + setSelectedCommit(null); + selectReference(reference); + }} + /> + + + + openDiff(commit)} + onCompareWithHead={(commit) => void viewBranchDiff(commit.hash)} + onCopyHash={(commit) => void copyCommitText(commit.hash, "Commit hash")} + onCopyMessage={(commit) => + void copyCommitText( + [commit.message, commit.description].filter(Boolean).join("\n\n"), + "Commit message", + ) + } + onLoadMore={() => void loadMore()} + /> + + + + + + + )} +
+ ); +} diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx new file mode 100644 index 000000000..0f1117538 --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx @@ -0,0 +1,190 @@ +import { + CaretDownIcon, + CaretRightIcon, + FolderIcon, + GitBranchIcon, + NetworkIcon, + TagIcon, +} from "@/ui/icons"; +import { useMemo } from "react"; +import { cn } from "@/utils/cn"; +import { useGitLogPreferencesStore } from "../../stores/git-log-preferences.store"; +import type { GitReference, GitReferenceKind } from "../../types/git.types"; +import { buildGitReferenceTree, type GitReferenceTreeNode } from "../../utils/git-reference-tree"; + +const SECTIONS: Array<{ kind: GitReferenceKind; title: string }> = [ + { kind: "local", title: "Local" }, + { kind: "remote", title: "Remote" }, + { kind: "tag", title: "Tags" }, +]; + +function ReferenceIcon({ kind }: { kind: GitReferenceKind }) { + if (kind === "tag") return ; + if (kind === "remote") return ; + return ; +} + +function ReferenceNode({ + node, + kind, + depth, + selectedFullName, + collapsedGroups, + onToggleGroup, + onSelect, +}: { + node: GitReferenceTreeNode; + kind: GitReferenceKind; + depth: number; + selectedFullName?: string; + collapsedGroups: Set; + onToggleGroup: (id: string) => void; + onSelect: (reference: GitReference) => void; +}) { + const isGroup = node.children.length > 0; + const isCollapsed = collapsedGroups.has(node.id); + const left = 10 + depth * 14; + + return ( + <> +
+ {isGroup ? ( + + ) : ( + + )} + +
+ {!isCollapsed && + node.children.map((child) => ( + + ))} + + ); +} + +export function GitReferenceTree({ + references, + selectedReference, + onSelect, +}: { + references: GitReference[]; + selectedReference: GitReference | null; + onSelect: (reference: GitReference | null) => void; +}) { + const collapsedSectionIds = useGitLogPreferencesStore.use.collapsedReferenceSections(); + const collapsedGroupIds = useGitLogPreferencesStore.use.collapsedReferenceGroups(); + const { toggleReferenceSection, toggleReferenceGroup } = useGitLogPreferencesStore.use.actions(); + const collapsedSections = useMemo(() => new Set(collapsedSectionIds), [collapsedSectionIds]); + const collapsedGroups = useMemo(() => new Set(collapsedGroupIds), [collapsedGroupIds]); + const currentReference = references.find((reference) => reference.isCurrent) ?? null; + const trees = useMemo( + () => new Map(SECTIONS.map(({ kind }) => [kind, buildGitReferenceTree(references, kind)])), + [references], + ); + + return ( +
+
+ References + {references.length} +
+
+ + + {SECTIONS.map(({ kind, title }) => { + const collapsed = collapsedSections.has(kind); + const nodes = trees.get(kind) ?? []; + return ( +
+ + {!collapsed && + (nodes.length ? ( + nodes.map((node) => ( + + )) + ) : ( +
None
+ ))} +
+ ); + })} +
+
+ ); +} diff --git a/windows/tauri/src/features/git/hooks/use-git-data-controller.ts b/windows/tauri/src/features/git/hooks/use-git-data-controller.ts index 1966e7d2a..6b6988383 100644 --- a/windows/tauri/src/features/git/hooks/use-git-data-controller.ts +++ b/windows/tauri/src/features/git/hooks/use-git-data-controller.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { getBranches } from "../api/git-branches-api"; import { getGitHistory } from "../api/git-commits-api"; +import { getOperationState } from "../api/git-integration-api"; import { getStashes } from "../api/git-stash-api"; import { getGitStatus } from "../api/git-status-api"; import { @@ -24,6 +25,7 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl useRepositoryStore.use.actions(); const gitActions = useGitStore((state) => state.actions); const gitStatus = useGitStore((state) => state.gitStatus); + const loadedCommitCount = useGitStore((state) => state.commits.length); const autoRefreshGitStatus = useSettingsStore((state) => state.settings.autoRefreshGitStatus); const requestIdRef = useRef(0); const refreshPromisesRef = useRef(new Map>()); @@ -40,11 +42,17 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl gitActions.setIsLoadingGitData(true); try { - const [status, history, branches, stashes] = await Promise.all([ + const [status, history, branches, stashes, operationStateResult] = await Promise.all([ getGitStatus(repoPath), getGitHistory(repoPath, 50), getBranches(repoPath), getStashes(repoPath), + getOperationState(repoPath) + .then((value) => ({ ok: true as const, value })) + .catch((error) => { + console.error("Failed to load Git operation state:", error); + return { ok: false as const }; + }), ]); if ( @@ -60,6 +68,7 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl hasMoreCommits: history?.hasMore ?? false, branches, stashes, + operationState: operationStateResult.ok ? operationStateResult.value : null, repoPath, }); } catch (error) { @@ -91,11 +100,21 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl refreshAll || scopes.includes("refs") || scopes.includes("repository"); const shouldRefreshStashes = refreshAll || scopes.includes("stashes") || scopes.includes("repository"); - const [status, branches, stashes, history] = await Promise.all([ + const [status, branches, stashes, history, operationStateResult] = await Promise.all([ getGitStatus(repoPath), shouldRefreshRefs ? getBranches(repoPath) : Promise.resolve(undefined), shouldRefreshStashes ? getStashes(repoPath) : Promise.resolve(undefined), - shouldRefreshHistory ? getGitHistory(repoPath, 50) : Promise.resolve(undefined), + shouldRefreshHistory + ? getGitHistory(repoPath, Math.max(loadedCommitCount, 50)) + : Promise.resolve(undefined), + // Operation state rides along on every refresh: staging a file or + // an external Git command can end a conflict at any moment. + getOperationState(repoPath) + .then((value) => ({ ok: true as const, value })) + .catch((error) => { + console.error("Failed to refresh Git operation state:", error); + return { ok: false as const }; + }), ]); if ( @@ -110,6 +129,7 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl branches, commits: history?.commits, hasMoreCommits: history?.hasMore, + operationState: operationStateResult.ok ? operationStateResult.value : undefined, repoPath, }); @@ -134,7 +154,7 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl refreshPromisesRef.current.set(refreshKey, request); return request; }, - [activeRepoPath, gitActions], + [activeRepoPath, gitActions, loadedCommitCount], ); const refresh = useCallback(async () => { diff --git a/windows/tauri/src/features/git/hooks/use-git-log-controller.ts b/windows/tauri/src/features/git/hooks/use-git-log-controller.ts new file mode 100644 index 000000000..be2e1b979 --- /dev/null +++ b/windows/tauri/src/features/git/hooks/use-git-log-controller.ts @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getGitHistory } from "../api/git-commits-api"; +import { subscribeToGitChanges } from "../events/git-events"; +import type { GitHistorySnapshot, GitReference } from "../types/git.types"; +import { shouldRefreshGitLogForChange } from "../utils/git-log-refresh"; + +type GitLogLoadState = "idle" | "loading" | "ready" | "failed"; + +const COMMITS_PER_PAGE = 50; +const MAX_COMMITS = 5_000; +const EMPTY_HISTORY: GitHistorySnapshot = { references: [], commits: [], hasMore: false }; + +export function useGitLogController(repoPath: string | null) { + const [history, setHistory] = useState(EMPTY_HISTORY); + const [loadState, setLoadState] = useState("idle"); + const [error, setError] = useState(null); + const [selectedReference, setSelectedReferenceState] = useState(null); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const requestIdRef = useRef(0); + const historyRef = useRef(history); + const selectedReferenceRef = useRef(selectedReference); + + historyRef.current = history; + selectedReferenceRef.current = selectedReference; + + const load = useCallback( + async ({ + reference, + limit, + loadingMore = false, + }: { + reference: GitReference | null; + limit: number; + loadingMore?: boolean; + }) => { + if (!repoPath) return; + + const requestId = ++requestIdRef.current; + setError(null); + if (loadingMore) setIsLoadingMore(true); + else setLoadState("loading"); + + try { + const snapshot = await getGitHistory(repoPath, limit, reference?.fullName); + if (requestId !== requestIdRef.current) return; + if (!snapshot) { + setLoadState("failed"); + setError("Unable to load Git history for this repository."); + return; + } + + setHistory({ + ...snapshot, + hasMore: snapshot.hasMore && limit < MAX_COMMITS, + }); + setLoadState("ready"); + } catch (loadError) { + if (requestId !== requestIdRef.current) return; + setLoadState("failed"); + setError(loadError instanceof Error ? loadError.message : "Unable to load Git history."); + } finally { + if (requestId === requestIdRef.current) setIsLoadingMore(false); + } + }, + [repoPath], + ); + + useEffect(() => { + requestIdRef.current += 1; + setHistory(EMPTY_HISTORY); + setSelectedReferenceState(null); + setIsLoadingMore(false); + setError(null); + + if (!repoPath) { + setLoadState("idle"); + return; + } + void load({ reference: null, limit: COMMITS_PER_PAGE }); + + return () => { + requestIdRef.current += 1; + }; + }, [load, repoPath]); + + const selectReference = useCallback( + (reference: GitReference | null) => { + selectedReferenceRef.current = reference; + setSelectedReferenceState(reference); + void load({ reference, limit: COMMITS_PER_PAGE }); + }, + [load], + ); + + const refresh = useCallback(() => { + const limit = Math.max(COMMITS_PER_PAGE, historyRef.current.commits.length); + return load({ reference: selectedReferenceRef.current, limit }); + }, [load]); + + useEffect(() => { + if (!repoPath) return; + + let timeoutId: ReturnType | null = null; + const unsubscribe = subscribeToGitChanges((change) => { + if (!shouldRefreshGitLogForChange(change, repoPath)) return; + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => void refresh(), 100); + }); + + return () => { + unsubscribe(); + if (timeoutId) clearTimeout(timeoutId); + }; + }, [refresh, repoPath]); + + const loadMore = useCallback(() => { + const currentHistory = historyRef.current; + if (!currentHistory.hasMore || isLoadingMore) return Promise.resolve(); + const limit = Math.min(currentHistory.commits.length + COMMITS_PER_PAGE, MAX_COMMITS); + return load({ reference: selectedReferenceRef.current, limit, loadingMore: true }); + }, [isLoadingMore, load]); + + return { + history, + loadState, + error, + selectedReference, + isLoadingMore, + selectReference, + refresh, + loadMore, + }; +} diff --git a/windows/tauri/src/features/git/stores/git-log-preferences.store.test.ts b/windows/tauri/src/features/git/stores/git-log-preferences.store.test.ts new file mode 100644 index 000000000..8ddc09bf1 --- /dev/null +++ b/windows/tauri/src/features/git/stores/git-log-preferences.store.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { useGitLogPreferencesStore } from "./git-log-preferences.store"; + +describe("Git Log preferences", () => { + test("persists read-only view preferences through focused actions", () => { + const actions = useGitLogPreferencesStore.getState().actions; + + actions.setFilterQuery("graph"); + actions.setFilterScope("author"); + actions.setShowDecorations(false); + actions.setMainPanelLayout({ references: 20, commits: 55, inspector: 25 }); + actions.setInspectorPanelLayout({ files: 70, details: 30 }); + actions.toggleReferenceSection("remote"); + actions.toggleReferenceGroup("remote:origin"); + + expect(useGitLogPreferencesStore.getState()).toMatchObject({ + filterQuery: "graph", + filterScope: "author", + showDecorations: false, + mainPanelLayout: { references: 20, commits: 55, inspector: 25 }, + inspectorPanelLayout: { files: 70, details: 30 }, + collapsedReferenceSections: ["remote"], + collapsedReferenceGroups: ["remote:origin"], + }); + + actions.setFilterQuery(""); + actions.setFilterScope("text"); + actions.setShowDecorations(true); + actions.setMainPanelLayout({ references: 19, commits: 57, inspector: 24 }); + actions.setInspectorPanelLayout({ files: 62, details: 38 }); + actions.toggleReferenceSection("remote"); + actions.toggleReferenceGroup("remote:origin"); + }); +}); diff --git a/windows/tauri/src/features/git/stores/git-log-preferences.store.ts b/windows/tauri/src/features/git/stores/git-log-preferences.store.ts new file mode 100644 index 000000000..44e09b21a --- /dev/null +++ b/windows/tauri/src/features/git/stores/git-log-preferences.store.ts @@ -0,0 +1,86 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { createSelectors } from "@/utils/zustand-selectors"; +import { createSafeJSONStorage } from "@/utils/zustand-storage"; +import type { GitReferenceKind } from "../types/git.types"; + +export type GitLogFilterScope = "text" | "author" | "branch"; + +export interface GitLogPanelLayout { + [panelId: string]: number; +} + +interface GitLogPreferencesStore { + filterQuery: string; + filterScope: GitLogFilterScope; + showDecorations: boolean; + mainPanelLayout: GitLogPanelLayout; + inspectorPanelLayout: GitLogPanelLayout; + collapsedReferenceSections: GitReferenceKind[]; + collapsedReferenceGroups: string[]; + actions: { + setFilterQuery: (query: string) => void; + setFilterScope: (scope: GitLogFilterScope) => void; + setShowDecorations: (show: boolean) => void; + setMainPanelLayout: (layout: GitLogPanelLayout) => void; + setInspectorPanelLayout: (layout: GitLogPanelLayout) => void; + toggleReferenceSection: (kind: GitReferenceKind) => void; + toggleReferenceGroup: (id: string) => void; + }; +} + +const DEFAULT_MAIN_LAYOUT: GitLogPanelLayout = { + references: 19, + commits: 57, + inspector: 24, +}; + +const DEFAULT_INSPECTOR_LAYOUT: GitLogPanelLayout = { + files: 62, + details: 38, +}; + +function toggleListItem(items: T[], item: T): T[] { + return items.includes(item) ? items.filter((value) => value !== item) : [...items, item]; +} + +const useGitLogPreferencesStoreBase = create()( + persist( + (set) => ({ + filterQuery: "", + filterScope: "text", + showDecorations: true, + mainPanelLayout: DEFAULT_MAIN_LAYOUT, + inspectorPanelLayout: DEFAULT_INSPECTOR_LAYOUT, + collapsedReferenceSections: [], + collapsedReferenceGroups: [], + actions: { + setFilterQuery: (filterQuery) => set({ filterQuery }), + setFilterScope: (filterScope) => set({ filterScope }), + setShowDecorations: (showDecorations) => set({ showDecorations }), + setMainPanelLayout: (mainPanelLayout) => set({ mainPanelLayout }), + setInspectorPanelLayout: (inspectorPanelLayout) => set({ inspectorPanelLayout }), + toggleReferenceSection: (kind) => + set((state) => ({ + collapsedReferenceSections: toggleListItem(state.collapsedReferenceSections, kind), + })), + toggleReferenceGroup: (id) => + set((state) => ({ + collapsedReferenceGroups: toggleListItem(state.collapsedReferenceGroups, id), + })), + }, + }), + { + name: "git-log-preferences", + storage: createSafeJSONStorage>(), + partialize: ({ actions: _, ...preferences }) => preferences, + merge: (persistedState, currentState) => ({ + ...currentState, + ...(persistedState as Partial), + actions: currentState.actions, + }), + }, + ), +); + +export const useGitLogPreferencesStore = createSelectors(useGitLogPreferencesStoreBase); diff --git a/windows/tauri/src/features/git/stores/git.store.test.ts b/windows/tauri/src/features/git/stores/git.store.test.ts index 70d18e330..03e761952 100644 --- a/windows/tauri/src/features/git/stores/git.store.test.ts +++ b/windows/tauri/src/features/git/stores/git.store.test.ts @@ -11,9 +11,12 @@ const { createGitStore } = await import("./git.store"); const commit = (index: number): GitCommit => ({ hash: `commit-${index}`, + shortHash: `commit-${index}`, + parentHashes: [], message: `Commit ${index}`, author: "Developer", date: "2026/08/16 10:00", + decorations: "", }); const commits = (count: number): GitCommit[] => @@ -31,6 +34,7 @@ const loadInitialHistory = ( hasMoreCommits: true, branches: [], stashes: [], + operationState: null, repoPath, }); }; @@ -43,7 +47,7 @@ describe("Git history pagination", () => { test("requests a larger cumulative snapshot instead of an ignored offset", async () => { const store = createGitStore(); loadInitialHistory(store, "C:/repo", commits(50)); - getGitHistory.mockResolvedValue({ commits: commits(100), hasMore: true }); + getGitHistory.mockResolvedValue({ references: [], commits: commits(100), hasMore: true }); await store.getState().actions.loadMoreCommits("C:/repo"); @@ -55,7 +59,7 @@ describe("Git history pagination", () => { test("uses the shared core hasMore flag at the end of history", async () => { const store = createGitStore(); loadInitialHistory(store, "C:/repo", commits(50)); - getGitHistory.mockResolvedValue({ commits: commits(73), hasMore: false }); + getGitHistory.mockResolvedValue({ references: [], commits: commits(73), hasMore: false }); await store.getState().actions.loadMoreCommits("C:/repo"); @@ -89,7 +93,7 @@ describe("Git history pagination", () => { const pending = store.getState().actions.loadMoreCommits("C:/repo-a"); store.getState().actions.prepareRepositoryLoad("C:/repo-b"); - resolveHistory({ commits: commits(100), hasMore: true }); + resolveHistory({ references: [], commits: commits(100), hasMore: true }); await pending; expect(store.getState().currentRepoPath).toBe("C:/repo-b"); @@ -97,3 +101,38 @@ describe("Git history pagination", () => { expect(store.getState().isLoadingMoreCommits).toBe(false); }); }); + +describe("Git operation state refresh", () => { + test("keeps the last operation state when a refresh omits a failed query", () => { + const store = createGitStore(); + store.getState().actions.prepareRepositoryLoad("C:/repo"); + store.getState().actions.loadFreshGitData({ + gitStatus: null, + commits: [], + hasMoreCommits: false, + branches: [], + stashes: [], + operationState: { + kind: "rebase", + reference: "refs/heads/main", + step: 2, + total: 4, + conflictedPaths: [], + }, + repoPath: "C:/repo", + }); + + store.getState().actions.refreshGitData({ + gitStatus: null, + repoPath: "C:/repo", + }); + + expect(store.getState().operationState).toEqual({ + kind: "rebase", + reference: "refs/heads/main", + step: 2, + total: 4, + conflictedPaths: [], + }); + }); +}); diff --git a/windows/tauri/src/features/git/stores/git.store.ts b/windows/tauri/src/features/git/stores/git.store.ts index 7223fd477..fdd324fc4 100644 --- a/windows/tauri/src/features/git/stores/git.store.ts +++ b/windows/tauri/src/features/git/stores/git.store.ts @@ -2,7 +2,7 @@ import { createStore } from "zustand/vanilla"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { getGitHistory } from "../api/git-commits-api"; import { getGitStatus } from "../api/git-status-api"; -import type { GitCommit, GitStash, GitStatus } from "../types/git.types"; +import type { GitCommit, GitOperationState, GitStash, GitStatus } from "../types/git.types"; interface GitState { gitStatus: GitStatus | null; @@ -10,6 +10,7 @@ interface GitState { commits: GitCommit[]; branches: string[]; stashes: GitStash[]; + operationState: GitOperationState | null; hasMoreCommits: boolean; isLoadingMoreCommits: boolean; isLoadingGitData: boolean; @@ -26,6 +27,7 @@ interface GitState { hasMoreCommits: boolean; branches: string[]; stashes: GitStash[]; + operationState: GitOperationState | null; repoPath: string; }) => void; refreshGitData: (data: { @@ -33,6 +35,7 @@ interface GitState { branches?: string[]; commits?: GitCommit[]; hasMoreCommits?: boolean; + operationState?: GitOperationState | null; repoPath: string; }) => void; refreshWorkspaceGitStatus: (repoPath: string) => Promise; @@ -58,6 +61,7 @@ export const createGitStore = () => commits: [], branches: [], stashes: [], + operationState: null, hasMoreCommits: true, isLoadingMoreCommits: false, isLoadingGitData: false, @@ -76,6 +80,7 @@ export const createGitStore = () => commits: [], branches: [], stashes: [], + operationState: null, hasMoreCommits: true, isLoadingMoreCommits: false, currentRepoPath: repoPath, @@ -88,6 +93,7 @@ export const createGitStore = () => hasMoreCommits, branches, stashes, + operationState, repoPath, }) => { if (get().currentRepoPath !== repoPath) { @@ -99,18 +105,20 @@ export const createGitStore = () => commits, branches, stashes, + operationState, hasMoreCommits, currentRepoPath: repoPath, }); }, - refreshGitData: ({ gitStatus, branches, commits, hasMoreCommits, repoPath }) => { + refreshGitData: ({ gitStatus, branches, commits, hasMoreCommits, operationState, repoPath }) => { if (get().currentRepoPath !== repoPath) { return; } set({ gitStatus, + ...(operationState !== undefined ? { operationState } : {}), ...(branches ? { branches } : {}), ...(commits ? { @@ -183,6 +191,7 @@ export const createGitStore = () => commits: [], branches: [], stashes: [], + operationState: null, hasMoreCommits: true, isLoadingMoreCommits: false, isLoadingGitData: false, diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index d9646703b..392f12d85 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -13,18 +13,37 @@ export interface GitStatus { export interface GitCommit { hash: string; + shortHash: string; + parentHashes: string[]; message: string; description?: string; author: string; email?: string; date: string; + decorations: string; +} + +export type GitReferenceKind = "local" | "remote" | "tag"; + +export interface GitReference { + fullName: string; + shortName: string; + kind: GitReferenceKind; + isCurrent: boolean; + upstreamShortName?: string; } export interface GitHistorySnapshot { + references: GitReference[]; commits: GitCommit[]; hasMore: boolean; } +export interface GitCommitFile { + status: string; + path: string; +} + export interface GitDiffLine { line_type: "added" | "removed" | "context" | "header"; content: string; @@ -116,3 +135,17 @@ export interface GitBlameLine { time: number; commit: string; } + +export type GitOperationKind = "merge" | "rebase" | "cherryPick" | "revert"; + +/** + * An in-progress merge/rebase/cherry-pick/revert detected from the repository's + * Git marker files, so operations started outside the app are reported too. + */ +export interface GitOperationState { + kind: GitOperationKind; + reference: string | null; + step: number | null; + total: number | null; + conflictedPaths: string[]; +} diff --git a/windows/tauri/src/features/git/utils/git-graph-layout.test.ts b/windows/tauri/src/features/git/utils/git-graph-layout.test.ts new file mode 100644 index 000000000..fd6825147 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-graph-layout.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import type { GitCommit } from "../types/git.types"; +import { layoutGitGraph, parseGitDecorations } from "./git-graph-layout"; + +const commit = ( + hash: string, + parentHashes: string[] = [], + decorations = "", +): GitCommit => ({ + hash, + shortHash: hash, + parentHashes, + message: hash, + author: "Developer", + date: "2026/08/16 10:00", + decorations, +}); + +describe("Git graph layout", () => { + test("keeps a linear history in one fixed lane", () => { + const layout = layoutGitGraph([commit("three", ["two"]), commit("two", ["one"]), commit("one")]); + + expect(layout.laneCount).toBe(1); + expect(layout.rows.map((row) => row.lane)).toEqual([0, 0, 0]); + expect(layout.hasMissingParents).toBe(false); + }); + + test("opens a stable secondary lane for a merge parent", () => { + const layout = layoutGitGraph([ + commit("merge", ["feature", "root"], "HEAD -> main"), + commit("feature", ["root"], "feature/orders"), + commit("root"), + ]); + + expect(layout.rows[0].parentEdges).toHaveLength(2); + expect(layout.rows[0].parentEdges.map((edge) => edge.targetLane)).toEqual([0, 1]); + expect(layout.rows[1].parentEdges[0].targetLane).toBe(1); + expect(layout.rows[2].lane).toBe(1); + expect(layout.hasMissingParents).toBe(false); + }); + + test("marks parents outside the cumulative snapshot as missing", () => { + const layout = layoutGitGraph([commit("visible", ["not-loaded"])]); + + expect(layout.hasMissingParents).toBe(true); + expect(layout.rows[0].parentEdges[0]).toMatchObject({ targetLane: null, isMissing: true }); + }); + + test("parses head, branch, remote, and tag decorations", () => { + expect(parseGitDecorations("HEAD -> main, origin/main, tag: v1.0.0")).toEqual([ + { title: "HEAD", kind: "head" }, + { title: "main", kind: "branch" }, + { title: "origin/main", kind: "remote" }, + { title: "v1.0.0", kind: "tag" }, + ]); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-graph-layout.ts b/windows/tauri/src/features/git/utils/git-graph-layout.ts new file mode 100644 index 000000000..8c3d458c9 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-graph-layout.ts @@ -0,0 +1,150 @@ +import type { GitCommit } from "../types/git.types"; + +export type GitGraphLabelKind = "head" | "branch" | "remote" | "tag"; + +export interface GitGraphLabel { + title: string; + kind: GitGraphLabelKind; +} + +export interface GitGraphEdge { + id: string; + parentHash: string; + targetLane: number | null; + colorIndex: number; + isMissing: boolean; +} + +export interface GitGraphRow { + commit: GitCommit; + lane: number; + laneCount: number; + incomingLaneColors: Array; + parentEdges: GitGraphEdge[]; + labels: GitGraphLabel[]; +} + +export interface GitGraphLayout { + rows: GitGraphRow[]; + laneCount: number; + hasMissingParents: boolean; +} + +interface Lane { + hash: string; + colorIndex: number; +} + +function claimSlot(slots: Array): number { + const freeSlot = slots.findIndex((slot) => slot === null); + if (freeSlot >= 0) return freeSlot; + slots.push(null); + return slots.length - 1; +} + +export function parseGitDecorations(decorations: string): GitGraphLabel[] { + return decorations.split(",").flatMap((value): GitGraphLabel[] => { + const raw = value.trim(); + if (!raw) return []; + if (raw === "HEAD") return [{ title: "HEAD", kind: "head" }]; + if (raw.startsWith("HEAD -> ")) { + return [ + { title: "HEAD", kind: "head" }, + { title: raw.slice("HEAD -> ".length), kind: "branch" }, + ]; + } + if (raw.startsWith("tag: ")) return [{ title: raw.slice("tag: ".length), kind: "tag" }]; + if (raw.startsWith("refs/tags/")) { + return [{ title: raw.slice("refs/tags/".length), kind: "tag" }]; + } + if (raw.startsWith("origin/") || raw.startsWith("refs/remotes/")) { + return [ + { + title: raw.startsWith("refs/remotes/") ? raw.slice("refs/remotes/".length) : raw, + kind: "remote", + }, + ]; + } + return [{ title: raw, kind: "branch" }]; + }); +} + +export function layoutGitGraph(commits: GitCommit[]): GitGraphLayout { + if (commits.length === 0) return { rows: [], laneCount: 0, hasMissingParents: false }; + + const knownHashes = new Set(commits.map((commit) => commit.hash)); + const slots: Array = []; + const rows: GitGraphRow[] = []; + let nextColorIndex = 0; + let maximumLaneCount = 0; + let hasMissingParents = false; + + for (const commit of commits) { + let currentLane = slots.findIndex((slot) => slot?.hash === commit.hash); + if (currentLane < 0) { + currentLane = claimSlot(slots); + slots[currentLane] = { hash: commit.hash, colorIndex: nextColorIndex++ }; + } + + const incomingLaneColors = slots.map((slot) => slot?.colorIndex ?? null); + const currentColorIndex = slots[currentLane]?.colorIndex ?? 0; + slots[currentLane] = null; + + const parentEdges: GitGraphEdge[] = []; + commit.parentHashes.forEach((parentHash, parentIndex) => { + if (!knownHashes.has(parentHash)) { + hasMissingParents = true; + parentEdges.push({ + id: `${commit.hash}:${parentIndex}:${parentHash}`, + parentHash, + targetLane: null, + colorIndex: parentIndex === 0 ? currentColorIndex : nextColorIndex, + isMissing: true, + }); + return; + } + + let targetLane = slots.findIndex((slot) => slot?.hash === parentHash); + let colorIndex: number; + if (targetLane >= 0) { + colorIndex = slots[targetLane]?.colorIndex ?? currentColorIndex; + } else if (parentIndex === 0) { + targetLane = currentLane; + colorIndex = currentColorIndex; + slots[targetLane] = { hash: parentHash, colorIndex }; + } else { + targetLane = claimSlot(slots); + colorIndex = nextColorIndex++; + slots[targetLane] = { hash: parentHash, colorIndex }; + } + + parentEdges.push({ + id: `${commit.hash}:${parentIndex}:${parentHash}`, + parentHash, + targetLane, + colorIndex, + isMissing: false, + }); + }); + + while (slots.length > 0 && slots[slots.length - 1] === null) slots.pop(); + const edgeLaneCount = Math.max(-1, ...parentEdges.map((edge) => edge.targetLane ?? -1)) + 1; + const laneCount = Math.max( + incomingLaneColors.length, + slots.length, + currentLane + 1, + edgeLaneCount, + ); + maximumLaneCount = Math.max(maximumLaneCount, laneCount); + rows.push({ + commit, + lane: currentLane, + laneCount, + incomingLaneColors, + parentEdges, + labels: parseGitDecorations(commit.decorations), + }); + } + + return { rows, laneCount: Math.max(1, maximumLaneCount), hasMissingParents }; +} diff --git a/windows/tauri/src/features/git/utils/git-log-filter.test.ts b/windows/tauri/src/features/git/utils/git-log-filter.test.ts new file mode 100644 index 000000000..0d0e9da9c --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-filter.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import type { GitCommit } from "../types/git.types"; +import { matchesGitLogCommit } from "./git-log-filter"; + +const COMMIT: GitCommit = { + hash: "0123456789abcdef", + shortHash: "0123456", + parentHashes: ["fedcba9876543210"], + message: "Add Git graph", + description: "Render stable lanes", + author: "Lithe Developer", + email: "dev@example.com", + date: "2026-08-16T10:00:00Z", + decorations: "HEAD -> preview/0.3.0, origin/preview/0.3.0, tag: v0.3.0", +}; + +describe("Git Log filters", () => { + test("matches text against the subject, body, and hashes without case sensitivity", () => { + expect(matchesGitLogCommit(COMMIT, "git GRAPH", "text")).toBe(true); + expect(matchesGitLogCommit(COMMIT, "stable lanes", "text")).toBe(true); + expect(matchesGitLogCommit(COMMIT, "0123456", "text")).toBe(true); + expect(matchesGitLogCommit(COMMIT, "someone else", "text")).toBe(false); + }); + + test("keeps author and branch searches scoped to their displayed metadata", () => { + expect(matchesGitLogCommit(COMMIT, "dev@example.com", "author")).toBe(true); + expect(matchesGitLogCommit(COMMIT, "origin/preview", "branch")).toBe(true); + expect(matchesGitLogCommit(COMMIT, "stable lanes", "author")).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-log-filter.ts b/windows/tauri/src/features/git/utils/git-log-filter.ts new file mode 100644 index 000000000..c3d18c5ed --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-filter.ts @@ -0,0 +1,19 @@ +import type { GitLogFilterScope } from "../stores/git-log-preferences.store"; +import type { GitCommit } from "../types/git.types"; + +export function matchesGitLogCommit( + commit: GitCommit, + query: string, + scope: GitLogFilterScope, +): boolean { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return true; + + const fields = + scope === "author" + ? [commit.author, commit.email ?? ""] + : scope === "branch" + ? [commit.decorations] + : [commit.message, commit.description ?? "", commit.hash, commit.shortHash]; + return fields.some((field) => field.toLocaleLowerCase().includes(normalizedQuery)); +} diff --git a/windows/tauri/src/features/git/utils/git-log-refresh.test.ts b/windows/tauri/src/features/git/utils/git-log-refresh.test.ts new file mode 100644 index 000000000..cbdab94f4 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-refresh.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { shouldRefreshGitLogForChange } from "./git-log-refresh"; + +describe("Git Log refresh events", () => { + test("refreshes for history, refs, and repository changes", () => { + const repoPath = "C:/work/project"; + + expect(shouldRefreshGitLogForChange({ repoPath, scopes: ["history"] }, repoPath)).toBe(true); + expect(shouldRefreshGitLogForChange({ repoPath, scopes: ["refs"] }, repoPath)).toBe(true); + expect(shouldRefreshGitLogForChange({ repoPath, scopes: ["repository"] }, repoPath)).toBe(true); + }); + + test("ignores working-tree-only and unrelated repository changes", () => { + const repoPath = "C:/work/project"; + + expect(shouldRefreshGitLogForChange({ repoPath, scopes: ["working-tree"] }, repoPath)).toBe( + false, + ); + expect( + shouldRefreshGitLogForChange( + { repoPath: "C:/work/other", scopes: ["history"] }, + repoPath, + ), + ).toBe(false); + }); + + test("refreshes conservatively when an event has no scopes", () => { + expect(shouldRefreshGitLogForChange({ repoPath: "C:/work/project" }, "C:/work/project")).toBe( + true, + ); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-log-refresh.ts b/windows/tauri/src/features/git/utils/git-log-refresh.ts new file mode 100644 index 000000000..f7ef0a0ea --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-log-refresh.ts @@ -0,0 +1,8 @@ +import { isGitChangeRelevant, type GitChange } from "../events/git-events"; + +const GIT_LOG_SCOPES = new Set(["history", "refs", "repository"]); + +export function shouldRefreshGitLogForChange(change: GitChange, repoPath: string): boolean { + if (!isGitChangeRelevant(change, repoPath)) return false; + return !change.scopes?.length || change.scopes.some((scope) => GIT_LOG_SCOPES.has(scope)); +} diff --git a/windows/tauri/src/features/git/utils/git-reference-tree.test.ts b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts new file mode 100644 index 000000000..674a79bb6 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-reference-tree.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import type { GitReference } from "../types/git.types"; +import { buildGitReferenceTree } from "./git-reference-tree"; + +const reference = (shortName: string): GitReference => ({ + fullName: `refs/remotes/${shortName}`, + shortName, + kind: "remote", + isCurrent: false, +}); + +describe("Git reference tree", () => { + test("groups slash-delimited references without losing leaf references", () => { + const tree = buildGitReferenceTree( + [reference("origin/main"), reference("origin/feature/orders")], + "remote", + ); + + expect(tree).toHaveLength(1); + expect(tree[0].name).toBe("origin"); + expect(tree[0].children.map((node) => node.name)).toEqual(["feature", "main"]); + expect(tree[0].children[0].children[0].reference?.shortName).toBe("origin/feature/orders"); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-reference-tree.ts b/windows/tauri/src/features/git/utils/git-reference-tree.ts new file mode 100644 index 000000000..6cc18d9cc --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-reference-tree.ts @@ -0,0 +1,49 @@ +import type { GitReference, GitReferenceKind } from "../types/git.types"; + +export interface GitReferenceTreeNode { + id: string; + name: string; + path: string; + reference?: GitReference; + children: GitReferenceTreeNode[]; +} + +interface MutableReferenceNode extends GitReferenceTreeNode { + children: MutableReferenceNode[]; +} + +export function buildGitReferenceTree( + references: GitReference[], + kind: GitReferenceKind, +): GitReferenceTreeNode[] { + const roots: MutableReferenceNode[] = []; + + for (const reference of references.filter((item) => item.kind === kind)) { + const parts = reference.shortName.split("/").filter(Boolean); + let siblings = roots; + let path = ""; + + parts.forEach((part, index) => { + path = path ? `${path}/${part}` : part; + let node = siblings.find((candidate) => candidate.name === part); + if (!node) { + node = { id: `${kind}:${path}`, name: part, path, children: [] }; + siblings.push(node); + } + if (index === parts.length - 1) node.reference = reference; + siblings = node.children; + }); + } + + const sortNodes = (nodes: MutableReferenceNode[]) => { + nodes.sort((left, right) => { + if (Boolean(left.children.length) !== Boolean(right.children.length)) { + return left.children.length ? -1 : 1; + } + return left.name.localeCompare(right.name); + }); + nodes.forEach((node) => sortNodes(node.children)); + }; + sortNodes(roots); + return roots; +} diff --git a/windows/tauri/src/features/keymaps/commands/command-registry.ts b/windows/tauri/src/features/keymaps/commands/command-registry.ts index bf2b89346..b33b81d22 100644 --- a/windows/tauri/src/features/keymaps/commands/command-registry.ts +++ b/windows/tauri/src/features/keymaps/commands/command-registry.ts @@ -100,6 +100,7 @@ import { toggleFilesSidebar, toggleDockerSidebar, toggleGitHubSidebar, + toggleGitLogPane, toggleLineNumbers, toggleMinimap, toggleRenderWhitespace, @@ -576,6 +577,13 @@ const viewCommands: Command[] = [ keybinding: "cmd+`", execute: toggleTerminalPane, }, + { + id: "workbench.toggleGitLog", + title: "Toggle Git Log", + category: "Git", + keybinding: "alt+9", + execute: toggleGitLogPane, + }, { id: "workbench.toggleDiagnostics", title: "Show Diagnostics", diff --git a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts index c6191149e..fa5ddf087 100644 --- a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts @@ -49,6 +49,16 @@ export function toggleTerminalPane(): void { } } +export function toggleGitLogPane(): void { + const state = useUIState.getState(); + if (state.isBottomPaneVisible && state.bottomPaneActiveTab === "gitLog") { + state.setIsBottomPaneVisible(false); + } else { + state.setBottomPaneActiveTab("gitLog"); + state.setIsBottomPaneVisible(true); + } +} + export function openDiagnosticsBuffer(): void { useBufferStore.getState().actions.openDiagnosticsBuffer(); } diff --git a/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts b/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts new file mode 100644 index 000000000..dbce7746a --- /dev/null +++ b/windows/tauri/src/features/keymaps/defaults/default-keymaps.git-log.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { FOOTER_LEADING_ITEM_IDS, normalizeItemOrder } from "@/features/layout/config/item-order"; +import { defaultKeymaps } from "./default-keymaps"; + +describe("Git Log workbench entry points", () => { + test("binds the IntelliJ-compatible Alt+9 shortcut", () => { + expect(defaultKeymaps).toContainEqual({ + key: "alt+9", + command: "workbench.toggleGitLog", + source: "default", + }); + }); + + test("adds the Git button to an existing persisted footer order", () => { + expect( + normalizeItemOrder(["branch", "terminal", "diagnostics"], FOOTER_LEADING_ITEM_IDS), + ).toEqual(["branch", "terminal", "diagnostics", "gitLog"]); + }); +}); diff --git a/windows/tauri/src/features/keymaps/defaults/default-keymaps.ts b/windows/tauri/src/features/keymaps/defaults/default-keymaps.ts index 5e6cd0ddd..5118863f7 100644 --- a/windows/tauri/src/features/keymaps/defaults/default-keymaps.ts +++ b/windows/tauri/src/features/keymaps/defaults/default-keymaps.ts @@ -466,6 +466,7 @@ export const defaultKeymaps: Keybinding[] = [ command: "workbench.commandPalette", source: "default", }, + { key: "alt+9", command: "workbench.toggleGitLog", source: "default" }, { key: "cmd+r", command: "workbench.toggleAIChat", source: "default" }, { key: "cmd+shift+m", command: "workbench.toggleMinimap", source: "default" }, { diff --git a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx index e870517fe..8c0f9c317 100644 --- a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx +++ b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx @@ -4,6 +4,7 @@ import { isBackendCapabilityAvailable } from "@/config/backend-capabilities"; import DebuggerView from "@/features/debugger/components/debugger-view"; import RunPane from "@/features/run/components/run-pane"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; +import { GitLogToolWindow } from "@/features/git/components/log/git-log-tool-window"; import { BOTTOM_PANE_ID } from "@/features/panes/constants/pane"; import { usePaneStore } from "@/features/panes/stores/pane.store"; import { activateBufferInPaneAndSync } from "@/features/panes/utils/pane-activation"; @@ -280,6 +281,12 @@ const BottomPane = () => { {bottomPaneBufferIds.length > 0 ? : null} )} + + {bottomPaneActiveTab === "gitLog" && ( +
+ +
+ )} ); diff --git a/windows/tauri/src/features/layout/components/footer/footer.tsx b/windows/tauri/src/features/layout/components/footer/footer.tsx index 2219431e8..acf625c44 100644 --- a/windows/tauri/src/features/layout/components/footer/footer.tsx +++ b/windows/tauri/src/features/layout/components/footer/footer.tsx @@ -18,6 +18,7 @@ import { orderChromeItems, type ChromeItem } from "@/features/layout/utils/chrom import { useFooterGitBranchItem } from "./footer-git-branch-item"; import { FooterTabControl } from "./footer-tab-control"; import { + ClockCounterClockwiseIcon, DatabaseIcon, TerminalWindowIcon, WarningIcon, @@ -55,6 +56,25 @@ const Footer = () => { ); const footerLeadingItemsSource: Array | null> = [ branchItem, + { + id: "gitLog", + label: "Git", + content: ( + { + const showingGitLog = !isBottomPaneVisible || bottomPaneActiveTab !== "gitLog"; + setBottomPaneActiveTab("gitLog"); + setIsBottomPaneVisible(showingGitLog); + }} + > + + Git + + ), + }, terminalEnabled ? { id: "terminal", diff --git a/windows/tauri/src/features/layout/config/item-order.ts b/windows/tauri/src/features/layout/config/item-order.ts index cb66ba020..78dd71730 100644 --- a/windows/tauri/src/features/layout/config/item-order.ts +++ b/windows/tauri/src/features/layout/config/item-order.ts @@ -9,6 +9,7 @@ export const SIDEBAR_ACTIVITY_ITEM_IDS = [ ] as const; export const FOOTER_LEADING_ITEM_IDS = [ "branch", + "gitLog", "terminal", "diagnostics", ] as const; diff --git a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts index c539c7476..c3613f502 100644 --- a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts +++ b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts @@ -19,7 +19,8 @@ export type BottomPaneTab = | "diagnostics" | "references" | "buffers" - | "run"; + | "run" + | "gitLog"; export interface QuickEditSelection { text: string; diff --git a/windows/tauri/src/platform/core-result-adapter.history.test.ts b/windows/tauri/src/platform/core-result-adapter.history.test.ts index 98f2735f5..7c6b46e3f 100644 --- a/windows/tauri/src/platform/core-result-adapter.history.test.ts +++ b/windows/tauri/src/platform/core-result-adapter.history.test.ts @@ -8,13 +8,25 @@ describe("git history result adaptation", () => { "git_log", { repoPath: "C:/work", limit: 50 }, { + references: [ + { + fullName: "refs/heads/main", + shortName: "main", + kind: "local", + isCurrent: true, + upstreamShortName: "origin/main", + }, + ], commits: [ { hash: "abc123", + shortHash: "abc123", + parentHashes: ["parent123"], subject: "First commit", authorName: "Developer", authorEmail: "developer@example.invalid", date: "2026/08/16 10:00", + decorations: "HEAD -> main, origin/main", }, ], hasMore: true, @@ -22,13 +34,25 @@ describe("git history result adaptation", () => { ); expect(result).toEqual({ + references: [ + { + fullName: "refs/heads/main", + shortName: "main", + kind: "local", + isCurrent: true, + upstreamShortName: "origin/main", + }, + ], commits: [ { hash: "abc123", + shortHash: "abc123", + parentHashes: ["parent123"], message: "First commit", author: "Developer", email: "developer@example.invalid", date: "2026/08/16 10:00", + decorations: "HEAD -> main, origin/main", }, ], hasMore: true, @@ -37,6 +61,7 @@ describe("git history result adaptation", () => { test("defaults missing history fields to an exhausted empty snapshot", () => { expect(adaptCoreResult("git_log", undefined, {})).toEqual({ + references: [], commits: [], hasMore: false, }); diff --git a/windows/tauri/src/platform/core-result-adapter.operation.test.ts b/windows/tauri/src/platform/core-result-adapter.operation.test.ts new file mode 100644 index 000000000..f58285785 --- /dev/null +++ b/windows/tauri/src/platform/core-result-adapter.operation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { adaptCoreResult } from "./core-result-adapter"; + +describe("git operation state adaptation", () => { + test("maps an in-progress rebase with progress counters", () => { + const state = adaptCoreResult( + "git_operation_state", + { repoPath: "C:/work" }, + { + kind: "rebase", + reference: "abc123", + step: 3, + total: 7, + conflictedPaths: ["src/main.rs", "README.md"], + }, + ); + + expect(state).toEqual({ + kind: "rebase", + reference: "abc123", + step: 3, + total: 7, + conflictedPaths: ["src/main.rs", "README.md"], + }); + }); + + test("returns null when no operation is in progress", () => { + const state = adaptCoreResult( + "git_operation_state", + { repoPath: "C:/work" }, + { kind: "", reference: null, step: null, total: null, conflictedPaths: [] }, + ); + + expect(state).toBeNull(); + }); + + test("keeps merge state without optional counters", () => { + const state = adaptCoreResult( + "git_operation_state", + { repoPath: "C:/work" }, + { kind: "merge", reference: null, step: null, total: null, conflictedPaths: ["a.txt"] }, + ); + + expect(state).toEqual({ + kind: "merge", + reference: null, + step: null, + total: null, + conflictedPaths: ["a.txt"], + }); + }); +}); + +describe("git integration preflight adaptation", () => { + test("passes through blocking paths and the blocks-entirely flag", () => { + const preflight = adaptCoreResult( + "git_integration_preflight", + { repoPath: "C:/work", branchName: "main", operation: "rebase" }, + { blockingPaths: ["a.txt", "b.txt"], blocksEntirely: true }, + ); + + expect(preflight).toEqual({ + blockingPaths: ["a.txt", "b.txt"], + blocksEntirely: true, + }); + }); +}); + +describe("git conflict marker adaptation", () => { + test("passes through staged files that still contain markers", () => { + const markers = adaptCoreResult( + "git_conflict_markers", + { repoPath: "C:/work" }, + { paths: ["src/conflicted.rs"] }, + ); + + expect(markers).toEqual({ paths: ["src/conflicted.rs"] }); + }); +}); diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts index 016ff75fb..8b525ff28 100644 --- a/windows/tauri/src/platform/core-result-adapter.ts +++ b/windows/tauri/src/platform/core-result-adapter.ts @@ -120,13 +120,25 @@ export function adaptCoreResult( } as T; case "git_log": return { + references: Array.isArray(data.references) + ? data.references.map((reference: JsonRecord) => ({ + fullName: reference.fullName, + shortName: reference.shortName, + kind: reference.kind, + isCurrent: Boolean(reference.isCurrent), + upstreamShortName: reference.upstreamShortName ?? undefined, + })) + : [], commits: Array.isArray(data.commits) ? data.commits.map((commit: JsonRecord) => ({ hash: commit.hash, + shortHash: commit.shortHash ?? String(commit.hash ?? "").slice(0, 7), + parentHashes: Array.isArray(commit.parentHashes) ? commit.parentHashes : [], message: commit.subject, author: commit.authorName, email: commit.authorEmail, date: commit.date, + decorations: commit.decorations ?? "", })) : [], hasMore: Boolean(data.hasMore), @@ -219,6 +231,37 @@ export function adaptCoreResult( } case "git_checkout_tag": return { success: true, hasChanges: false, message: "" } as T; + case "git_operation_state": { + const kind = typeof data.kind === "string" ? data.kind : ""; + if (!kind) { + return null as T; + } + const conflictedPaths = Array.isArray(data.conflictedPaths) + ? data.conflictedPaths.map((path: unknown) => String(path)) + : []; + return { + kind, + reference: typeof data.reference === "string" ? data.reference : null, + step: typeof data.step === "number" ? data.step : null, + total: typeof data.total === "number" ? data.total : null, + conflictedPaths, + } as T; + } + case "git_integration_preflight": { + const blockingPaths = Array.isArray(data.blockingPaths) + ? data.blockingPaths.map((path: unknown) => String(path)) + : []; + return { + blockingPaths, + blocksEntirely: Boolean(data.blocksEntirely), + } as T; + } + case "git_conflict_markers": { + const paths = Array.isArray(data.paths) + ? data.paths.map((path: unknown) => String(path)) + : []; + return { paths } as T; + } default: return value as T; }