diff --git a/docs/assets/screenshots/windows-git-split-addition.png b/docs/assets/screenshots/windows-git-split-addition.png new file mode 100644 index 000000000..c83287138 Binary files /dev/null and b/docs/assets/screenshots/windows-git-split-addition.png differ diff --git a/docs/assets/screenshots/windows-git-split-deletion.png b/docs/assets/screenshots/windows-git-split-deletion.png new file mode 100644 index 000000000..b57616015 Binary files /dev/null and b/docs/assets/screenshots/windows-git-split-deletion.png differ diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 5bfccd200..a3cdb70b6 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -90,8 +90,13 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git.write" } "git_diff_file" | "git_status_diff_stats" => { - paths_from_file(&mut payload); - payload.entry("pathspecs").or_insert_with(|| json!([])); + if let Some(path) = payload.remove("filePath") { + payload.insert("pathspecs".into(), Value::Array(vec![path])); + } else { + // The shared core rejects empty pathspecs; a request without a + // file scope means the whole tree, which git spells as `.`. + payload.insert("pathspecs".into(), json!(["."])); + } "git.diff" } "git_ref_diff" => { @@ -436,7 +441,41 @@ mod tests { translate("git_diff_file", json!({ "repoPath": "C:/work" })).unwrap(); assert_eq!(command, "git.diff"); - assert_eq!(payload, json!({ "root": "C:/work", "pathspecs": [] })); + assert_eq!(payload, json!({ "root": "C:/work", "pathspecs": ["."] })); + } + + #[test] + fn translates_diff_file_pathspec() { + let (command, payload) = translate( + "git_diff_file", + json!({ "repoPath": "C:/work", "filePath": "src/main.rs", "staged": true }), + ) + .unwrap(); + + assert_eq!(command, "git.diff"); + assert_eq!( + payload, + json!({ + "root": "C:/work", + "pathspecs": ["src/main.rs"], + "staged": true + }) + ); + } + + #[test] + fn translates_status_diff_stats_whole_tree() { + let (command, payload) = translate( + "git_status_diff_stats", + json!({ "repoPath": "C:/work", "staged": true }), + ) + .unwrap(); + + assert_eq!(command, "git.diff"); + assert_eq!( + payload, + json!({ "root": "C:/work", "pathspecs": ["."], "staged": true }) + ); } #[test] diff --git a/windows/tauri/src/features/editor/stores/buffer-pane-sync.ts b/windows/tauri/src/features/editor/stores/buffer-pane-sync.ts index d90e90ef2..68c169a70 100644 --- a/windows/tauri/src/features/editor/stores/buffer-pane-sync.ts +++ b/windows/tauri/src/features/editor/stores/buffer-pane-sync.ts @@ -2,7 +2,10 @@ import { usePaneStore } from "@/features/panes/stores/pane.store"; import type { PaneGroup } from "@/features/panes/types/pane.types"; import type { PaneContent } from "@/features/panes/types/pane-content.types"; import { ensureBufferInPane } from "@/features/panes/utils/pane-buffer-actions"; -import { resolveWritablePaneForBuffer } from "@/features/panes/utils/pane-routing"; +import { + resolveMainPaneForExternalOpen, + resolveWritablePaneForBuffer, +} from "@/features/panes/utils/pane-routing"; import { createPaneBeside } from "@/features/panes/utils/pane-split-actions"; const getPaneState = (workspaceId?: string) => @@ -29,6 +32,19 @@ export const getWritablePaneForBuffer = ( return newPaneId ? paneStore.actions.getPaneById(newPaneId) : activePane; }; +export const activateMainEditorPane = (workspaceId?: string): PaneGroup | null => { + const paneStore = getPaneState(workspaceId); + const targetPane = resolveMainPaneForExternalOpen({ + activePaneId: paneStore.activePaneId, + mostRecentActivePaneIds: paneStore.mostRecentActivePaneIds, + root: paneStore.root, + }); + if (targetPane && targetPane.id !== paneStore.activePaneId) { + paneStore.actions.setActivePane(targetPane.id); + } + return targetPane; +}; + export const syncBufferToPane = (bufferId: string, workspaceId?: string) => { const targetPane = getWritablePaneForBuffer(bufferId, workspaceId); if (!targetPane) return; 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 a43edf038..29c565dc9 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 } from "../types/git.types"; +import type { GitCommit, GitHistorySnapshot } from "../types/git.types"; import { emitGitChanged } from "../events/git-events"; import { runGitRead } from "../runtime/git-read-coordinator"; import { @@ -24,24 +24,29 @@ export const commitChanges = async (repoPath: string, message: string): Promise< } }; -export const getGitLog = async (repoPath: string, limit = 50, skip = 0): Promise => { +export const getGitHistory = async ( + repoPath: string, + limit = 50, +): Promise => { try { const resolvedRepoPath = await resolveRepositoryPath(repoPath); if (!resolvedRepoPath) { - return []; + return null; } - return await runGitRead(resolvedRepoPath, `log:${limit}:${skip}`, () => - tauriInvoke("git_log", { + return await runGitRead(resolvedRepoPath, `log:${limit}`, () => + tauriInvoke("git_log", { repoPath: resolvedRepoPath, limit, - skip, }), ); } catch (error) { if (!isNotGitRepositoryError(error)) { console.error("Failed to get git log:", error); } - return []; + return null; } }; + +export const getGitLog = async (repoPath: string, limit = 50): Promise => + (await getGitHistory(repoPath, limit))?.commits ?? []; diff --git a/windows/tauri/src/features/git/api/git-diff-api.ts b/windows/tauri/src/features/git/api/git-diff-api.ts index 389c72f4d..5ec665ff6 100644 --- a/windows/tauri/src/features/git/api/git-diff-api.ts +++ b/windows/tauri/src/features/git/api/git-diff-api.ts @@ -219,14 +219,20 @@ export const getStatusDiffStats = async (repoPath: string): Promise("git_status_diff_stats", { - repoPath: resolvedRepoPath, - }) - .then((stats) => { + const request = Promise.all([ + tauriInvoke("git_status_diff_stats", { + repoPath: resolvedRepoPath, + }), + tauriInvoke("git_status_diff_stats", { + repoPath: resolvedRepoPath, + staged: true, + }), + ]) + .then(([unstagedStats, stagedStats]) => { if (generation !== getRepositoryCacheGeneration(resolvedRepoPath)) { return getStatusDiffStats(resolvedRepoPath); } - return stats; + return [...unstagedStats, ...stagedStats]; }) .catch((error) => { if (!isNotGitRepositoryError(error)) { diff --git a/windows/tauri/src/features/git/api/git-repo-api.test.ts b/windows/tauri/src/features/git/api/git-repo-api.test.ts new file mode 100644 index 000000000..9cefff172 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-repo-api.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; + +const invoke = mock(async (_command: string, _args?: unknown): Promise => null); + +mock.module("@/platform/tauri-core", () => ({ invoke })); + +const { clearRepositoryDiscoveryCache, resolveRepositoryForFile } = await import("./git-repo-api"); + +beforeEach(() => { + invoke.mockReset(); + clearRepositoryDiscoveryCache(); +}); + +describe("resolveRepositoryForFile", () => { + test("discovers the repository from the file's directory", async () => { + invoke.mockResolvedValue("D:/work/project"); + + const result = await resolveRepositoryForFile("D:/work/project", "src/main.ts"); + + expect(invoke).toHaveBeenCalledWith("git_discover_repo", { + path: "D:/work/project/src", + }); + expect(result).toEqual({ + repoPath: "D:/work/project", + filePath: "src/main.ts", + }); + }); + + test("keeps absolute file paths relative to the discovered repository", async () => { + invoke.mockResolvedValue("D:/work/project"); + + const result = await resolveRepositoryForFile( + "D:/work", + "D:\\work\\project\\src\\main.ts", + ); + + expect(invoke).toHaveBeenCalledWith("git_discover_repo", { + path: "D:/work/project/src", + }); + expect(result).toEqual({ + repoPath: "D:/work/project", + filePath: "src/main.ts", + }); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-repo-api.ts b/windows/tauri/src/features/git/api/git-repo-api.ts index 1add83713..66073db2d 100644 --- a/windows/tauri/src/features/git/api/git-repo-api.ts +++ b/windows/tauri/src/features/git/api/git-repo-api.ts @@ -81,6 +81,16 @@ function joinPath(basePath: string, childPath: string): string { return normalizePath(`${base}/${child}`); } +function parentPath(path: string): string { + const normalized = normalizePath(path); + const separatorIndex = normalized.lastIndexOf("/"); + if (separatorIndex < 0) return normalized; + if (separatorIndex === 2 && /^[A-Za-z]:\//.test(normalized)) { + return normalized.slice(0, separatorIndex + 1); + } + return separatorIndex === 0 ? "/" : normalized.slice(0, separatorIndex); +} + function toRelativePath(from: string, to: string): string { const normalizedFrom = normalizePath(from); const normalizedTo = normalizePath(to); @@ -205,7 +215,7 @@ export async function resolveRepositoryForFile( filePath: string, ): Promise<{ repoPath: string; filePath: string } | null> { const absoluteFilePath = isAbsolutePath(filePath) ? filePath : joinPath(repoPath, filePath); - const discoveredRepo = await discoverRepo(absoluteFilePath); + const discoveredRepo = await discoverRepo(parentPath(absoluteFilePath)); if (!discoveredRepo) { return null; diff --git a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx index 6165513dd..c83d273ff 100644 --- a/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx +++ b/windows/tauri/src/features/git/components/diff/git-diff-editor-stack.tsx @@ -25,6 +25,7 @@ import { calculateLineHeight, splitLines } from "@/features/editor/utils/lines"; import { useZoomStore } from "@/features/window/stores/zoom.store"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { useGitDiffPreferencesStore } from "@/features/git/stores/git-diff-preferences.store"; import { buildSearchRegex, findAllMatches, @@ -650,7 +651,8 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); const isFindVisible = useUIState((state) => state.isFindVisible); const setIsFindVisible = useUIState((state) => state.setIsFindVisible); - const [viewMode, setViewMode] = useState<"unified" | "split">("unified"); + const viewMode = useGitDiffPreferencesStore.use.viewMode(); + const setViewMode = useGitDiffPreferencesStore.use.actions().setViewMode; const [showWhitespace, setShowWhitespace] = useState(false); const [isFileTreeVisible, setIsFileTreeVisible] = useState(true); const [fileNavigatorViewMode, setFileNavigatorViewMode] = useState("tree"); @@ -1034,8 +1036,12 @@ const GitDiffEditorStack = memo(function GitDiffEditorStack({ {multiDiff.title || "Uncommitted Changes"} {indexedFileLabel} - +{multiDiff.totalAdditions} - -{multiDiff.totalDeletions} + {multiDiff.totalAdditions > 0 ? ( + +{multiDiff.totalAdditions} + ) : null} + {multiDiff.totalDeletions > 0 ? ( + -{multiDiff.totalDeletions} + ) : null} {isIndexingDiffs ? {indexingLabel} : null} } diff --git a/windows/tauri/src/features/git/components/diff/git-diff-header.tsx b/windows/tauri/src/features/git/components/diff/git-diff-header.tsx index 1ab7437cc..5c0687146 100644 --- a/windows/tauri/src/features/git/components/diff/git-diff-header.tsx +++ b/windows/tauri/src/features/git/components/diff/git-diff-header.tsx @@ -14,7 +14,7 @@ import Breadcrumb, { import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { cn } from "@/utils/cn"; import type { DiffHeaderProps } from "../../types/git-diff.types"; -import { getFileStatus } from "../../utils/git-diff-helpers"; +import { countDiffStats, getFileStatus } from "../../utils/git-diff-helpers"; const DiffHeader = memo( ({ @@ -45,12 +45,7 @@ const DiffHeader = memo( const renderStats = () => { if (!diff) return null; - let additions = 0; - let deletions = 0; - for (const l of diff.lines) { - if (l.line_type === "added") additions++; - else if (l.line_type === "removed") deletions++; - } + const { additions, deletions } = countDiffStats([diff]); return ( <> diff --git a/windows/tauri/src/features/git/components/diff/git-diff-hunk-header.tsx b/windows/tauri/src/features/git/components/diff/git-diff-hunk-header.tsx index d7417f061..30fc9cafe 100644 --- a/windows/tauri/src/features/git/components/diff/git-diff-hunk-header.tsx +++ b/windows/tauri/src/features/git/components/diff/git-diff-hunk-header.tsx @@ -18,6 +18,7 @@ import { createGitHunk, parseDiffHunkRange } from "../../utils/git-diff-helpers" const DiffHunkHeader = memo( ({ hunk, + stats, hiddenLineCount, isCollapsed, onToggleCollapse, @@ -66,11 +67,13 @@ const DiffHunkHeader = memo( [rootFolderPath, filePath, hunk, isStaged, onStageHunk, onUnstageHunk], ); - let additions = 0; - let deletions = 0; - for (const l of hunk.lines) { - if (l.line_type === "added") additions++; - else if (l.line_type === "removed") deletions++; + let additions = stats?.additions ?? 0; + let deletions = stats?.deletions ?? 0; + if (!stats) { + for (const l of hunk.lines) { + if (l.line_type === "added") additions++; + else if (l.line_type === "removed") deletions++; + } } const headerInfo = parseDiffHunkRange(hunk.header.content); diff --git a/windows/tauri/src/features/git/components/diff/git-diff-text.tsx b/windows/tauri/src/features/git/components/diff/git-diff-text.tsx index 701906e99..e8f76fd50 100644 --- a/windows/tauri/src/features/git/components/diff/git-diff-text.tsx +++ b/windows/tauri/src/features/git/components/diff/git-diff-text.tsx @@ -10,8 +10,14 @@ import type { ParsedHunk, TextDiffViewerProps, } from "../../types/git-diff.types"; +import type { GitDiffSplitRow } from "../../types/git.types"; import { DIFF_HIGHLIGHT_LINE_THRESHOLD } from "../../utils/diff-viewer-scale"; -import { getSkippedUnchangedLineCount, groupLinesIntoHunks } from "../../utils/git-diff-helpers"; +import { + createFallbackSplitRows, + countSplitDiffStats, + getSkippedUnchangedLineCount, + groupLinesIntoHunks, +} from "../../utils/git-diff-helpers"; import DiffHunkHeader from "./git-diff-hunk-header"; import DiffLine, { getContentColor, @@ -19,13 +25,13 @@ import DiffLine, { getGutterTextColor, getLineBackground, getRailClassName, - getSplitLineMeta, renderDiffLineContent, } from "./git-diff-line"; function SplitDiffCodePanel({ side, - lines, + rows, + sourceLines, tokenMap, showWhitespace, fontSize, @@ -34,7 +40,8 @@ function SplitDiffCodePanel({ searchHighlights, }: { side: "left" | "right"; - lines: ParsedHunk["lines"]; + rows: GitDiffSplitRow[]; + sourceLines: ParsedHunk["lines"]; tokenMap: ReturnType; showWhitespace: boolean; fontSize: number; @@ -42,6 +49,15 @@ function SplitDiffCodePanel({ tabSize: number; searchHighlights?: Map; }) { + const sourceLinesByNumber = useMemo(() => { + const entries = sourceLines.flatMap((line) => { + const lineNumber = side === "left" ? line.old_line_number : line.new_line_number; + const visible = side === "left" ? line.line_type !== "added" : line.line_type !== "removed"; + return visible && lineNumber !== undefined ? [[lineNumber, line] as const] : []; + }); + return new Map(entries); + }, [side, sourceLines]); + const contentStyle = { fontSize: `${fontSize}px`, lineHeight: `${lineHeight}px`, @@ -54,19 +70,28 @@ function SplitDiffCodePanel({ return (
- {lines.map((line, index) => { - const meta = getSplitLineMeta(line, side); + {rows.map((row, index) => { + const lineNumber = side === "left" ? row.old_line_number : row.new_line_number; + const content = side === "left" ? row.old_content : row.new_content; + const isVisible = content !== undefined; + const diffType = isVisible + ? side === "left" && (row.kind === "changed" || row.kind === "removal") + ? "removed" + : side === "right" && (row.kind === "changed" || row.kind === "addition") + ? "added" + : "context" + : "context"; return (
- {meta.isVisible ? meta.gutterNumber : ""} + {isVisible ? lineNumber : ""}
); })} @@ -74,23 +99,33 @@ function SplitDiffCodePanel({
- {lines.map((line, index) => { - const meta = getSplitLineMeta(line, side); - const tokens = tokenMap.get(line.diffIndex); + {rows.map((row, index) => { + const lineNumber = side === "left" ? row.old_line_number : row.new_line_number; + const content = side === "left" ? row.old_content : row.new_content; + const sourceLine = lineNumber === undefined ? undefined : sourceLinesByNumber.get(lineNumber); + const isVisible = content !== undefined; + const diffType = isVisible + ? side === "left" && (row.kind === "changed" || row.kind === "removal") + ? "removed" + : side === "right" && (row.kind === "changed" || row.kind === "addition") + ? "added" + : "context" + : "context"; + const tokens = sourceLine ? tokenMap.get(sourceLine.diffIndex) : undefined; return (
- - {meta.isVisible + + {isVisible ? renderDiffLineContent( - line.content, + content, tokens, showWhitespace, - searchHighlights?.get(line.diffIndex), + sourceLine ? searchHighlights?.get(sourceLine.diffIndex) : undefined, ) : ""} @@ -169,10 +204,12 @@ const TextDiffViewer = memo( {hunks.map((hunk, hunkIndex) => { const isCollapsed = collapsedHunks.has(hunk.id); const hiddenLineCount = getSkippedUnchangedLineCount(hunks[hunkIndex - 1], hunk); + const splitRows = diff.split_hunks?.[hunkIndex] ?? createFallbackSplitRows(hunk.lines); return (
toggleHunkCollapse(hunk.id)} @@ -187,7 +224,8 @@ const TextDiffViewer = memo(
{ const isCollapsed = collapsedHunks.has(hunk.id); const hiddenLineCount = getSkippedUnchangedLineCount(hunks[hunkIndex - 1], hunk); + const splitRows = diff.split_hunks?.[hunkIndex] ?? createFallbackSplitRows(hunk.lines); return (
toggleHunkCollapse(hunk.id)} 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 d7e203a1e..2a4b51189 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.tsx @@ -112,7 +112,7 @@ async function buildCommitMessageContext({ const stagedFilesForContext = stagedFiles.slice(0, MAX_STAGED_FILES_FOR_AI_CONTEXT); const diffFilesForContext = stagedFiles.slice(0, MAX_DIFF_FILES_FOR_AI_CONTEXT); const [recentCommits, stagedDiffs] = await Promise.all([ - getGitLog(repoPath, MAX_RECENT_COMMITS_FOR_AI_CONTEXT, 0), + getGitLog(repoPath, MAX_RECENT_COMMITS_FOR_AI_CONTEXT), Promise.all(diffFilesForContext.map((file) => getFileDiff(repoPath, file.path, true))), ]); const overflowCount = Math.max(stagedFiles.length - stagedFilesForContext.length, 0); 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 267bf069d..1966e7d2a 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 @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from "react"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { getBranches } from "../api/git-branches-api"; -import { getGitLog } from "../api/git-commits-api"; +import { getGitHistory } from "../api/git-commits-api"; import { getStashes } from "../api/git-stash-api"; import { getGitStatus } from "../api/git-status-api"; import { @@ -40,9 +40,9 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl gitActions.setIsLoadingGitData(true); try { - const [status, commits, branches, stashes] = await Promise.all([ + const [status, history, branches, stashes] = await Promise.all([ getGitStatus(repoPath), - getGitLog(repoPath, 50, 0), + getGitHistory(repoPath, 50), getBranches(repoPath), getStashes(repoPath), ]); @@ -56,7 +56,8 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl gitActions.loadFreshGitData({ gitStatus: status, - commits, + commits: history?.commits ?? [], + hasMoreCommits: history?.hasMore ?? false, branches, stashes, repoPath, @@ -90,11 +91,11 @@ 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, commits] = await Promise.all([ + const [status, branches, stashes, history] = await Promise.all([ getGitStatus(repoPath), shouldRefreshRefs ? getBranches(repoPath) : Promise.resolve(undefined), shouldRefreshStashes ? getStashes(repoPath) : Promise.resolve(undefined), - shouldRefreshHistory ? getGitLog(repoPath, 50, 0) : Promise.resolve(undefined), + shouldRefreshHistory ? getGitHistory(repoPath, 50) : Promise.resolve(undefined), ]); if ( @@ -107,7 +108,8 @@ export function useGitDataController({ workspacePath, isActive }: GitDataControl gitActions.refreshGitData({ gitStatus: status, branches, - commits, + commits: history?.commits, + hasMoreCommits: history?.hasMore, repoPath, }); diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts index b1cff41cb..564517639 100644 --- a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts +++ b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import { activateMainEditorPane } from "@/features/editor/stores/buffer-pane-sync"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { showAlertDialog } from "@/ui/dialog"; import { getCommitDiff, getFileDiff, getRefDiff, getStashDiff } from "../api/git-diff-api"; @@ -29,6 +30,7 @@ function openDiffBuffer( displayName: string, diffData: GitDiff | MultiFileDiff, ) { + activateMainEditorPane(); return useBufferStore .getState() .actions.openBuffer(virtualPath, displayName, "", false, undefined, true, true, diffData); @@ -100,6 +102,7 @@ export function useGitDiffActions({ try { const actualFilePath = normalizeDisplayedFilePath(filePath, "new"); + activateMainEditorPane(); onFileSelect(`${activeRepoPath}/${actualFilePath}`, false); } catch (error) { console.error("Error opening file:", error); diff --git a/windows/tauri/src/features/git/stores/git-diff-preferences.store.test.ts b/windows/tauri/src/features/git/stores/git-diff-preferences.store.test.ts new file mode 100644 index 000000000..674ac31d2 --- /dev/null +++ b/windows/tauri/src/features/git/stores/git-diff-preferences.store.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test"; +import { useGitDiffPreferencesStore } from "./git-diff-preferences.store"; + +describe("git diff preferences store", () => { + test("defaults to split view like the macOS reference", () => { + expect(useGitDiffPreferencesStore.getState().viewMode).toBe("split"); + }); + + test("setViewMode switches the preference in both directions", () => { + const { setViewMode } = useGitDiffPreferencesStore.getState().actions; + + setViewMode("unified"); + expect(useGitDiffPreferencesStore.getState().viewMode).toBe("unified"); + + setViewMode("split"); + expect(useGitDiffPreferencesStore.getState().viewMode).toBe("split"); + }); +}); diff --git a/windows/tauri/src/features/git/stores/git-diff-preferences.store.ts b/windows/tauri/src/features/git/stores/git-diff-preferences.store.ts new file mode 100644 index 000000000..e530e8893 --- /dev/null +++ b/windows/tauri/src/features/git/stores/git-diff-preferences.store.ts @@ -0,0 +1,39 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { createSelectors } from "@/utils/zustand-selectors"; +import { createSafeJSONStorage } from "@/utils/zustand-storage"; + +export type GitDiffViewMode = "unified" | "split"; + +interface GitDiffPreferencesStore { + // Split matches the macOS reference product and IDEA; users who prefer the + // unified layout switch once and the choice persists across sessions. + viewMode: GitDiffViewMode; + actions: { + setViewMode: (mode: GitDiffViewMode) => void; + }; +} + +const useGitDiffPreferencesStoreBase = create()( + persist( + (set) => ({ + viewMode: "split", + + actions: { + setViewMode: (viewMode) => set({ viewMode }), + }, + }), + { + name: "git-diff-preferences", + storage: createSafeJSONStorage>(), + partialize: ({ viewMode }) => ({ viewMode }), + merge: (persistedState, currentState) => ({ + ...currentState, + ...(persistedState as Pick), + actions: currentState.actions, + }), + }, + ), +); + +export const useGitDiffPreferencesStore = createSelectors(useGitDiffPreferencesStoreBase); diff --git a/windows/tauri/src/features/git/stores/git.store.test.ts b/windows/tauri/src/features/git/stores/git.store.test.ts new file mode 100644 index 000000000..70d18e330 --- /dev/null +++ b/windows/tauri/src/features/git/stores/git.store.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import type { GitCommit, GitHistorySnapshot } from "../types/git.types"; + +const getGitHistory = mock( + async (_repoPath: string, _limit: number): Promise => null, +); + +mock.module("../api/git-commits-api", () => ({ getGitHistory })); + +const { createGitStore } = await import("./git.store"); + +const commit = (index: number): GitCommit => ({ + hash: `commit-${index}`, + message: `Commit ${index}`, + author: "Developer", + date: "2026/08/16 10:00", +}); + +const commits = (count: number): GitCommit[] => + Array.from({ length: count }, (_, index) => commit(index)); + +const loadInitialHistory = ( + store: ReturnType, + repoPath: string, + initialCommits: GitCommit[], +) => { + store.getState().actions.prepareRepositoryLoad(repoPath); + store.getState().actions.loadFreshGitData({ + gitStatus: null, + commits: initialCommits, + hasMoreCommits: true, + branches: [], + stashes: [], + repoPath, + }); +}; + +beforeEach(() => { + getGitHistory.mockReset(); +}); + +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 }); + + await store.getState().actions.loadMoreCommits("C:/repo"); + + expect(getGitHistory).toHaveBeenCalledWith("C:/repo", 100); + expect(store.getState().commits).toHaveLength(100); + expect(store.getState().hasMoreCommits).toBe(true); + }); + + 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 }); + + await store.getState().actions.loadMoreCommits("C:/repo"); + + expect(store.getState().commits).toHaveLength(73); + expect(store.getState().hasMoreCommits).toBe(false); + }); + + test("keeps the current snapshot when loading more fails", async () => { + const store = createGitStore(); + loadInitialHistory(store, "C:/repo", commits(50)); + getGitHistory.mockResolvedValue(null); + + await store.getState().actions.loadMoreCommits("C:/repo"); + + expect(store.getState().commits).toHaveLength(50); + expect(store.getState().hasMoreCommits).toBe(true); + expect(store.getState().isLoadingMoreCommits).toBe(false); + }); + + test("discards a completed request after switching repositories", async () => { + const store = createGitStore(); + loadInitialHistory(store, "C:/repo-a", commits(50)); + + let resolveHistory: (snapshot: GitHistorySnapshot) => void = () => {}; + getGitHistory.mockImplementation( + () => + new Promise((resolve) => { + resolveHistory = resolve; + }), + ); + + const pending = store.getState().actions.loadMoreCommits("C:/repo-a"); + store.getState().actions.prepareRepositoryLoad("C:/repo-b"); + resolveHistory({ commits: commits(100), hasMore: true }); + await pending; + + expect(store.getState().currentRepoPath).toBe("C:/repo-b"); + expect(store.getState().commits).toEqual([]); + expect(store.getState().isLoadingMoreCommits).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/git/stores/git.store.ts b/windows/tauri/src/features/git/stores/git.store.ts index 5eb5334cf..7223fd477 100644 --- a/windows/tauri/src/features/git/stores/git.store.ts +++ b/windows/tauri/src/features/git/stores/git.store.ts @@ -1,6 +1,6 @@ import { createStore } from "zustand/vanilla"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; -import { getGitLog } from "../api/git-commits-api"; +import { getGitHistory } from "../api/git-commits-api"; import { getGitStatus } from "../api/git-status-api"; import type { GitCommit, GitStash, GitStatus } from "../types/git.types"; @@ -23,6 +23,7 @@ interface GitState { loadFreshGitData: (data: { gitStatus: GitStatus | null; commits: GitCommit[]; + hasMoreCommits: boolean; branches: string[]; stashes: GitStash[]; repoPath: string; @@ -31,6 +32,7 @@ interface GitState { gitStatus: GitStatus | null; branches?: string[]; commits?: GitCommit[]; + hasMoreCommits?: boolean; repoPath: string; }) => void; refreshWorkspaceGitStatus: (repoPath: string) => Promise; @@ -47,6 +49,7 @@ interface GitState { } const COMMITS_PER_PAGE = 50; +const MAX_COMMITS = 5_000; export const createGitStore = () => createStore()((set, get) => ({ @@ -79,7 +82,14 @@ export const createGitStore = () => }); }, - loadFreshGitData: ({ gitStatus, commits, branches, stashes, repoPath }) => { + loadFreshGitData: ({ + gitStatus, + commits, + hasMoreCommits, + branches, + stashes, + repoPath, + }) => { if (get().currentRepoPath !== repoPath) { return; } @@ -89,12 +99,12 @@ export const createGitStore = () => commits, branches, stashes, - hasMoreCommits: commits.length >= COMMITS_PER_PAGE, + hasMoreCommits, currentRepoPath: repoPath, }); }, - refreshGitData: ({ gitStatus, branches, commits, repoPath }) => { + refreshGitData: ({ gitStatus, branches, commits, hasMoreCommits, repoPath }) => { if (get().currentRepoPath !== repoPath) { return; } @@ -105,7 +115,7 @@ export const createGitStore = () => ...(commits ? { commits, - hasMoreCommits: commits.length >= COMMITS_PER_PAGE, + hasMoreCommits: hasMoreCommits ?? false, } : {}), }); @@ -129,27 +139,28 @@ export const createGitStore = () => if (currentRepoPath !== repoPath || !hasMoreCommits || isLoadingMoreCommits) return; + if (commits.length >= MAX_COMMITS) { + set({ hasMoreCommits: false }); + return; + } + set({ isLoadingMoreCommits: true }); try { - const newCommits = await getGitLog(repoPath, COMMITS_PER_PAGE, commits.length); - if (get().currentRepoPath !== repoPath) { + const requestedLimit = Math.min(commits.length + COMMITS_PER_PAGE, MAX_COMMITS); + const history = await getGitHistory(repoPath, requestedLimit); + if (!history || get().currentRepoPath !== repoPath) { return; } - const existingHashSet = new Set(commits.map((c) => c.hash)); - const uniqueNewCommits = newCommits.filter((c) => !existingHashSet.has(c.hash)); - - if (uniqueNewCommits.length > 0) { - set({ - commits: [...commits, ...uniqueNewCommits], - hasMoreCommits: uniqueNewCommits.length >= COMMITS_PER_PAGE, - }); - } else { - set({ hasMoreCommits: false }); - } + set({ + commits: history.commits, + hasMoreCommits: history.hasMore && requestedLimit < MAX_COMMITS, + }); } finally { - set({ isLoadingMoreCommits: false }); + if (get().currentRepoPath === repoPath) { + set({ isLoadingMoreCommits: false }); + } } }, diff --git a/windows/tauri/src/features/git/types/git-diff.types.ts b/windows/tauri/src/features/git/types/git-diff.types.ts index 97ca938d6..2b424993f 100644 --- a/windows/tauri/src/features/git/types/git-diff.types.ts +++ b/windows/tauri/src/features/git/types/git-diff.types.ts @@ -44,6 +44,7 @@ export interface DiffHeaderProps { export interface DiffHunkHeaderProps { hunk: ParsedHunk; + stats?: { additions: number; deletions: number }; hiddenLineCount?: number | null; isCollapsed: boolean; onToggleCollapse: () => void; diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index 2760d042c..d9646703b 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -20,6 +20,11 @@ export interface GitCommit { date: string; } +export interface GitHistorySnapshot { + commits: GitCommit[]; + hasMore: boolean; +} + export interface GitDiffLine { line_type: "added" | "removed" | "context" | "header"; content: string; @@ -27,6 +32,14 @@ export interface GitDiffLine { new_line_number?: number; } +export interface GitDiffSplitRow { + kind: "context" | "changed" | "addition" | "removal"; + old_line_number?: number; + new_line_number?: number; + old_content?: string; + new_content?: string; +} + export interface GitDiff { file_path: string; old_path?: string; @@ -43,6 +56,7 @@ export interface GitDiff { additions?: number; deletions?: number; is_truncated?: boolean; + split_hunks?: GitDiffSplitRow[][]; } export interface GitDiffStat { diff --git a/windows/tauri/src/features/git/utils/git-diff-helpers.test.ts b/windows/tauri/src/features/git/utils/git-diff-helpers.test.ts new file mode 100644 index 000000000..c94961f37 --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-diff-helpers.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import type { DiffLineWithIndex } from "../types/git-diff.types"; +import { countSplitDiffStats, createFallbackSplitRows } from "./git-diff-helpers"; + +describe("split diff fallback alignment", () => { + test("pairs removals and additions into shared visual rows", () => { + const lines: DiffLineWithIndex[] = [ + { + line_type: "context", + content: "a", + old_line_number: 1, + new_line_number: 1, + diffIndex: 1, + }, + { + line_type: "removed", + content: "aa", + old_line_number: 2, + diffIndex: 2, + }, + { + line_type: "added", + content: "aa", + new_line_number: 2, + diffIndex: 3, + }, + { + line_type: "added", + content: "abc", + new_line_number: 3, + diffIndex: 4, + }, + ]; + + const rows = createFallbackSplitRows(lines); + expect(rows).toEqual([ + { + kind: "context", + old_line_number: 1, + new_line_number: 1, + old_content: "a", + new_content: "a", + }, + { + kind: "context", + old_line_number: 2, + new_line_number: 2, + old_content: "aa", + new_content: "aa", + }, + { + kind: "addition", + old_line_number: undefined, + new_line_number: 3, + old_content: undefined, + new_content: "abc", + }, + ]); + expect(countSplitDiffStats([rows])).toEqual({ additions: 1, deletions: 0 }); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-diff-helpers.ts b/windows/tauri/src/features/git/utils/git-diff-helpers.ts index 5212cd3fb..903381d85 100644 --- a/windows/tauri/src/features/git/utils/git-diff-helpers.ts +++ b/windows/tauri/src/features/git/utils/git-diff-helpers.ts @@ -1,5 +1,5 @@ import type { DiffLineWithIndex, ParsedHunk } from "../types/git-diff.types"; -import type { GitDiff, GitDiffLine, GitHunk } from "../types/git.types"; +import type { GitDiff, GitDiffLine, GitDiffSplitRow, GitHunk } from "../types/git.types"; export { getDiffLineVisualState, getDiffLineVisualType } from "./diff-viewer-visuals"; export interface DiffHunkRange { @@ -104,6 +104,72 @@ export function groupLinesIntoHunks(lines: GitDiffLine[]): ParsedHunk[] { return hunks; } +export function createFallbackSplitRows(lines: DiffLineWithIndex[]): GitDiffSplitRow[] { + const rows: GitDiffSplitRow[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + if (line.line_type === "context") { + rows.push({ + kind: "context", + old_line_number: line.old_line_number, + new_line_number: line.new_line_number, + old_content: line.content, + new_content: line.content, + }); + index++; + continue; + } + + const removed: DiffLineWithIndex[] = []; + const added: DiffLineWithIndex[] = []; + while (index < lines.length && lines[index].line_type !== "context") { + const changedLine = lines[index]; + if (changedLine.line_type === "removed") removed.push(changedLine); + if (changedLine.line_type === "added") added.push(changedLine); + index++; + } + + const rowCount = Math.max(removed.length, added.length); + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + const oldLine = removed[rowIndex]; + const newLine = added[rowIndex]; + rows.push({ + kind: + oldLine && newLine + ? oldLine.content === newLine.content + ? "context" + : "changed" + : oldLine + ? "removal" + : "addition", + old_line_number: oldLine?.old_line_number, + new_line_number: newLine?.new_line_number, + old_content: oldLine?.content, + new_content: newLine?.content, + }); + } + } + + return rows; +} + +export function countSplitDiffStats(splitHunks: GitDiffSplitRow[][]): { + additions: number; + deletions: number; +} { + let additions = 0; + let deletions = 0; + + for (const row of splitHunks.flat()) { + if (row.kind === "addition" || row.kind === "changed") additions++; + if (row.kind === "removal" || row.kind === "changed") deletions++; + } + + return { additions, deletions }; +} + export function countDiffStats(diffs: GitDiff[]): { additions: number; deletions: number } { let additions = 0; let deletions = 0; diff --git a/windows/tauri/src/features/git/utils/git-diff-parser.ts b/windows/tauri/src/features/git/utils/git-diff-parser.ts index bc2f23bb8..76a94b9f9 100644 --- a/windows/tauri/src/features/git/utils/git-diff-parser.ts +++ b/windows/tauri/src/features/git/utils/git-diff-parser.ts @@ -160,6 +160,8 @@ function parseDiffSection(lines: string[], fallbackFilePath: string): GitDiff { }); currentOldLine++; currentNewLine++; + } else if (line.startsWith("\\ No newline at end of file")) { + continue; } else if (line.trim()) { diffLines.push({ line_type: "context", diff --git a/windows/tauri/src/features/panes/utils/pane-routing.test.ts b/windows/tauri/src/features/panes/utils/pane-routing.test.ts new file mode 100644 index 000000000..77040613d --- /dev/null +++ b/windows/tauri/src/features/panes/utils/pane-routing.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import type { PaneGroup, PaneNode } from "../types/pane.types"; +import { resolveMainPaneForExternalOpen } from "./pane-routing"; + +const pane = (id: string): PaneGroup => ({ + id, + type: "group", + bufferIds: [], + activeBufferId: null, +}); + +describe("main pane routing for external opens", () => { + test("keeps an active main editor pane", () => { + const mainPane = pane("main"); + + expect( + resolveMainPaneForExternalOpen({ + activePaneId: "main", + mostRecentActivePaneIds: ["main"], + root: mainPane, + }), + ).toBe(mainPane); + }); + + test("routes away from the bottom pane to the most recent main pane", () => { + const firstPane = pane("first"); + const recentPane = pane("recent"); + const root: PaneNode = { + id: "main-split", + type: "split", + direction: "horizontal", + children: [firstPane, recentPane], + sizes: [50, 50], + }; + + expect( + resolveMainPaneForExternalOpen({ + activePaneId: "bottom-pane", + mostRecentActivePaneIds: ["bottom-pane", "recent", "first"], + root, + }), + ).toBe(recentPane); + }); + + test("falls back to the first main pane when history has no main pane", () => { + const firstPane = pane("first"); + const secondPane = pane("second"); + const root: PaneNode = { + id: "main-split", + type: "split", + direction: "vertical", + children: [firstPane, secondPane], + sizes: [50, 50], + }; + + expect( + resolveMainPaneForExternalOpen({ + activePaneId: "bottom-pane", + mostRecentActivePaneIds: ["bottom-pane"], + root, + }), + ).toBe(firstPane); + }); +}); diff --git a/windows/tauri/src/features/panes/utils/pane-routing.ts b/windows/tauri/src/features/panes/utils/pane-routing.ts index 035b1cfe9..51da5f01d 100644 --- a/windows/tauri/src/features/panes/utils/pane-routing.ts +++ b/windows/tauri/src/features/panes/utils/pane-routing.ts @@ -9,6 +9,28 @@ export interface WritablePaneRoutingInput { root: PaneNode; } +export interface MainPaneRoutingInput { + activePaneId: string; + mostRecentActivePaneIds: string[]; + root: PaneNode; +} + +export function resolveMainPaneForExternalOpen({ + activePaneId, + mostRecentActivePaneIds, + root, +}: MainPaneRoutingInput): PaneGroup | null { + const mainPanes = getAllPaneGroups(root); + const paneById = new Map(mainPanes.map((pane) => [pane.id, pane] as const)); + + return ( + paneById.get(activePaneId) ?? + mostRecentActivePaneIds.map((paneId) => paneById.get(paneId)).find(Boolean) ?? + mainPanes[0] ?? + null + ); +} + export function getPaneScopeForPaneId(root: PaneNode, bottomRoot: PaneNode, paneId: string) { const rootPanes = getAllPaneGroups(root); if (rootPanes.some((pane) => pane.id === paneId)) { diff --git a/windows/tauri/src/platform/core-result-adapter.diff.test.ts b/windows/tauri/src/platform/core-result-adapter.diff.test.ts new file mode 100644 index 000000000..140d1d1f7 --- /dev/null +++ b/windows/tauri/src/platform/core-result-adapter.diff.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import type { GitDiff } from "@/features/git/types/git.types"; +import { adaptCoreResult } from "./core-result-adapter"; + +const twoFilePatch = `diff --git a/a.txt b/a.txt +index 111..222 100644 +--- a/a.txt ++++ b/a.txt +@@ -1 +1,2 @@ + hello ++world +diff --git a/b.txt b/b.txt +index 333..444 100644 +--- a/b.txt ++++ b/b.txt +@@ -1 +0,0 @@ +-old line +`; + +describe("git diff stats adaptation", () => { + test("maps per-file additions and deletions from the whole tree patch", () => { + const stats = adaptCoreResult( + "git_status_diff_stats", + { repoPath: "C:/work" }, + { patch: twoFilePatch }, + ); + + expect(stats).toEqual([ + { file_path: "a.txt", staged: false, additions: 1, deletions: 0 }, + { file_path: "b.txt", staged: false, additions: 0, deletions: 1 }, + ]); + }); + + test("carries the staged flag from the request into every stat entry", () => { + const stats = adaptCoreResult( + "git_status_diff_stats", + { repoPath: "C:/work", staged: true }, + { patch: twoFilePatch }, + ); + + expect(Array.isArray(stats)).toBe(true); + for (const stat of stats as Array<{ staged: boolean }>) { + expect(stat.staged).toBe(true); + } + }); +}); + +describe("git single-file diff adaptation", () => { + test("returns the parsed diff for the requested file", () => { + const diff = adaptCoreResult( + "git_diff_file", + { repoPath: "C:/work", filePath: "a.txt" }, + { patch: twoFilePatch }, + ); + + expect((diff as { file_path: string }).file_path).toBe("a.txt"); + }); + + test("preserves Core's aligned rows for the split viewer", () => { + const patch = `diff --git a/a.txt b/a.txt +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,3 @@ + a +-aa +\\ No newline at end of file ++aa ++abc +\\ No newline at end of file +`; + const diff = adaptCoreResult( + "git_diff_file", + { repoPath: "C:/work", filePath: "a.txt" }, + { + patch, + rows: [ + { kind: "information", left: "@@ -1,2 +1,3 @@", hunkId: "hunk-0" }, + { kind: "context", oldLine: 1, newLine: 1, left: "a", hunkId: "hunk-0" }, + { kind: "changed", oldLine: 2, newLine: 2, left: "aa", right: "aa", hunkId: "hunk-0" }, + { kind: "addition", newLine: 3, right: "abc", hunkId: "hunk-0" }, + ], + }, + ) as GitDiff; + + expect(diff.split_hunks).toEqual([ + [ + { + kind: "context", + old_line_number: 1, + new_line_number: 1, + old_content: "a", + new_content: "a", + }, + { + kind: "context", + old_line_number: 2, + new_line_number: 2, + old_content: "aa", + new_content: "aa", + }, + { + kind: "addition", + old_line_number: undefined, + new_line_number: 3, + old_content: undefined, + new_content: "abc", + }, + ], + ]); + expect({ additions: diff.additions, deletions: diff.deletions }).toEqual({ + additions: 1, + deletions: 0, + }); + expect(diff.lines.some((line) => line.content.includes("No newline"))).toBe(false); + }); + + test("uses semantic stats when an unchanged EOF line is paired", () => { + const patch = `diff --git a/a.txt b/a.txt +--- a/a.txt ++++ b/a.txt +@@ -1,2 +1,3 @@ + a +-aa +\\ No newline at end of file ++aa ++abc +\\ No newline at end of file +`; + const stats = adaptCoreResult( + "git_status_diff_stats", + { repoPath: "C:/work" }, + { + patch, + rows: [ + { kind: "information", left: "@@ -1,2 +1,3 @@", hunkId: "hunk-0" }, + { kind: "context", oldLine: 1, newLine: 1, left: "a", hunkId: "hunk-0" }, + { kind: "changed", oldLine: 2, newLine: 2, left: "aa", right: "aa", hunkId: "hunk-0" }, + { kind: "addition", newLine: 3, right: "abc", hunkId: "hunk-0" }, + ], + }, + ); + + expect(stats).toEqual([ + { file_path: "a.txt", staged: false, additions: 1, deletions: 0 }, + ]); + }); +}); diff --git a/windows/tauri/src/platform/core-result-adapter.history.test.ts b/windows/tauri/src/platform/core-result-adapter.history.test.ts new file mode 100644 index 000000000..98f2735f5 --- /dev/null +++ b/windows/tauri/src/platform/core-result-adapter.history.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import type { GitHistorySnapshot } from "@/features/git/types/git.types"; +import { adaptCoreResult } from "./core-result-adapter"; + +describe("git history result adaptation", () => { + test("preserves commits and the shared core pagination state", () => { + const result = adaptCoreResult( + "git_log", + { repoPath: "C:/work", limit: 50 }, + { + commits: [ + { + hash: "abc123", + subject: "First commit", + authorName: "Developer", + authorEmail: "developer@example.invalid", + date: "2026/08/16 10:00", + }, + ], + hasMore: true, + }, + ); + + expect(result).toEqual({ + commits: [ + { + hash: "abc123", + message: "First commit", + author: "Developer", + email: "developer@example.invalid", + date: "2026/08/16 10:00", + }, + ], + hasMore: true, + }); + }); + + test("defaults missing history fields to an exhausted empty snapshot", () => { + expect(adaptCoreResult("git_log", undefined, {})).toEqual({ + commits: [], + hasMore: false, + }); + }); +}); diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts index dc94de2c9..522f181c2 100644 --- a/windows/tauri/src/platform/core-result-adapter.ts +++ b/windows/tauri/src/platform/core-result-adapter.ts @@ -1,4 +1,6 @@ import { parseRawDiffContent } from "@/features/git/utils/git-diff-parser"; +import type { GitDiff, GitDiffSplitRow } from "@/features/git/types/git.types"; +import { countSplitDiffStats } from "@/features/git/utils/git-diff-helpers"; type JsonRecord = Record; @@ -14,6 +16,59 @@ function normalizeStatus(status: string, untracked: boolean): string { return "modified"; } +function adaptStructuredSplitHunks(value: unknown): GitDiffSplitRow[][] { + if (!Array.isArray(value)) return []; + + const hunks: GitDiffSplitRow[][] = []; + let currentHunk: GitDiffSplitRow[] | null = null; + + for (const item of value) { + const row = asRecord(item); + const kind = String(row.kind ?? ""); + if (kind === "information") { + if (currentHunk) hunks.push(currentHunk); + currentHunk = []; + continue; + } + if (!currentHunk) currentHunk = []; + if (!["context", "changed", "addition", "removal"].includes(kind)) continue; + + const left = typeof row.left === "string" ? row.left : undefined; + const right = typeof row.right === "string" ? row.right : kind === "context" ? left : undefined; + const visualKind = kind === "changed" && left === right ? "context" : kind; + currentHunk.push({ + kind: visualKind as GitDiffSplitRow["kind"], + old_line_number: typeof row.oldLine === "number" ? row.oldLine : undefined, + new_line_number: typeof row.newLine === "number" ? row.newLine : undefined, + old_content: left, + new_content: right, + }); + } + + if (currentHunk) hunks.push(currentHunk); + return hunks; +} + +function attachStructuredSplitHunks(diffs: GitDiff[], value: unknown): GitDiff[] { + const availableHunks = adaptStructuredSplitHunks(value); + let hunkOffset = 0; + + return diffs.map((diff) => { + const hunkCount = diff.lines.filter((line) => line.line_type === "header").length; + const splitHunks = availableHunks.slice(hunkOffset, hunkOffset + hunkCount); + hunkOffset += hunkCount; + if (splitHunks.length !== hunkCount || hunkCount === 0) return diff; + + const stats = countSplitDiffStats(splitHunks); + return { + ...diff, + split_hunks: splitHunks, + additions: stats.additions, + deletions: stats.deletions, + }; + }); +} + function adaptDiff(command: string, args: JsonRecord | undefined, value: unknown): unknown { const data = asRecord(value); const patch = typeof data.patch === "string" ? data.patch : ""; @@ -24,20 +79,22 @@ function adaptDiff(command: string, args: JsonRecord | undefined, value: unknown argumentsRecord.baseRef ?? `git-${command}.diff`; const parsed = parseRawDiffContent(patch, String(fallback)); + const parsedDiffs = "files" in parsed ? parsed.files : [parsed]; + const diffs = attachStructuredSplitHunks(parsedDiffs, data.rows); if (command === "git_diff_file") { - return "files" in parsed ? parsed.files[0] ?? null : parsed; + return diffs[0] ?? null; } if (command === "git_status_diff_stats") { - const diffs = "files" in parsed ? parsed.files : [parsed]; + const staged = Boolean(argumentsRecord.staged); return diffs.map((diff) => ({ file_path: diff.file_path, - staged: false, + staged, additions: diff.additions ?? diff.lines.filter((line) => line.line_type === "added").length, deletions: diff.deletions ?? diff.lines.filter((line) => line.line_type === "removed").length, })); } - return "files" in parsed ? parsed.files : [parsed]; + return diffs; } export function adaptCoreResult( @@ -62,15 +119,18 @@ export function adaptCoreResult( : [], } as T; case "git_log": - return (Array.isArray(data.commits) - ? data.commits.map((commit: JsonRecord) => ({ - hash: commit.hash, - message: commit.subject, - author: commit.authorName, - email: commit.authorEmail, - date: commit.date, - })) - : []) as T; + return { + commits: Array.isArray(data.commits) + ? data.commits.map((commit: JsonRecord) => ({ + hash: commit.hash, + message: commit.subject, + author: commit.authorName, + email: commit.authorEmail, + date: commit.date, + })) + : [], + hasMore: Boolean(data.hasMore), + } as T; case "git_branches": return (Array.isArray(data.references) ? data.references