+
{t(hasLoadError ? "git.statusLoadFailed" : "git.historyLoadFailed")}
-
@@ -691,7 +679,11 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {
{renderActionsButton()}
-
+
{renderGitActionsMenu({ hasGitRepo: false, onRefresh: handleManualRefresh })}
>
@@ -721,8 +713,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {
updateSourceControlSession({ collapsedFolders: [...folders].sort() })
@@ -745,6 +736,7 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {
setStashSearchQuery("");
}}
onStagingRefresh={refreshWorkingTree}
+ onStagingPendingChange={setIsStaging}
onRefresh={refreshAfterAction}
repoPath={activeRepoPath}
/>
@@ -768,6 +760,11 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {
updateSourceControlSession({ commitMessage })
@@ -776,10 +773,6 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => {
repoPath={activeRepoPath}
ahead={gitStatus.ahead}
behind={gitStatus.behind}
- onCommitSuccess={() => {
- updateSourceControlSession({ commitSelectedPaths: [], commitMessage: "" });
- void refreshAfterAction();
- }}
onPull={handlePull}
isPulling={pullWorkflow.isPulling}
isPullLocked={pullWorkflow.isPullLocked}
diff --git a/windows/tauri/src/features/git/components/git-workspace-commit-review.test.tsx b/windows/tauri/src/features/git/components/git-workspace-commit-review.test.tsx
new file mode 100644
index 000000000..47d29e5d7
--- /dev/null
+++ b/windows/tauri/src/features/git/components/git-workspace-commit-review.test.tsx
@@ -0,0 +1,44 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { installHappyDom } from "@/test-utils/happy-dom";
+import { LocaleProvider } from "@/i18n/locale-provider";
+import fixture from "../../../../../../shared/fixtures/git/workspace-commit-workflow-v1.json";
+
+let restoreDom: () => void;
+let root: Root;
+let container: HTMLDivElement;
+const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
+let previousAct: boolean | undefined;
+beforeEach(() => {
+ restoreDom = installHappyDom();
+ previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT;
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove(); restoreDom();
+ if (previousAct === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT;
+ else actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct;
+});
+test("review renders Core child-first order, file paths, parent references and changed-plan notice", async () => {
+ const { GitWorkspaceCommitReview } = await import("./git-workspace-commit-review");
+ let confirmed = false;
+ await act(async () => root.render(
+ { confirmed = true; }} onClose={() => {}} onIncludeParents={() => {}} />
+ ));
+ const text = document.body.textContent ?? "";
+ expect(text.indexOf("1. A/B")).toBeLessThan(text.indexOf("2. A"));
+ expect(text).toContain("hello.ts");
+ expect(text).toContain("Update reference: B → A/B");
+ expect(text).toContain("The plan changed.");
+ expect(text).toContain("refs/heads/main");
+ const button = [...document.querySelectorAll("button")].find((button) => button.textContent === "Commit and Push");
+ expect(button).toBeDefined();
+ await act(async () => button!.click());
+ expect(confirmed).toBe(true);
+});
diff --git a/windows/tauri/src/features/git/components/git-workspace-commit-review.tsx b/windows/tauri/src/features/git/components/git-workspace-commit-review.tsx
new file mode 100644
index 000000000..c320efdc4
--- /dev/null
+++ b/windows/tauri/src/features/git/components/git-workspace-commit-review.tsx
@@ -0,0 +1,105 @@
+import { useTranslation } from "@/i18n/locale-provider";
+import { Button } from "@/ui/button";
+import { Checkbox } from "@/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/ui/dialog";
+import type { WorkspaceCommitPreparation } from "../types/git-workspace-commit.types";
+
+export function GitWorkspaceCommitReview({
+ preparation,
+ busy,
+ error,
+ onConfirm,
+ onClose,
+ onIncludeParents,
+}: {
+ preparation: WorkspaceCommitPreparation;
+ busy: boolean;
+ error: string | null;
+ onConfirm: () => void;
+ onClose: () => void;
+ onIncludeParents: (include: boolean) => void;
+}) {
+ const { t } = useTranslation();
+ const plan = preparation.session.plan;
+ return (
+
+ );
+}
diff --git a/windows/tauri/src/features/git/components/status/git-status-file-item.tsx b/windows/tauri/src/features/git/components/status/git-status-file-item.tsx
index cbeed4deb..18e5c3cce 100644
--- a/windows/tauri/src/features/git/components/status/git-status-file-item.tsx
+++ b/windows/tauri/src/features/git/components/status/git-status-file-item.tsx
@@ -110,7 +110,7 @@ export const GitFileItem = ({
stagePending = false,
}: GitFileItemProps) => {
const { t } = useTranslation();
- const pathParts = file.path.split("/");
+ const pathParts = getGitFileRepositoryRelativePath(file).split("/");
const fileName = pathParts.pop() || file.path;
const directory = pathParts.join("/");
const dragRepoPath = getGitFileRepositoryPath(file, repoPath);
@@ -177,7 +177,7 @@ export const GitFileItem = ({
name: fileName,
});
}}
- title={file.path}
+ title={file.canToggleStaging === false ? t("git.workspaceCommit.dirtySubmodule") : file.path}
/>
);
};
diff --git a/windows/tauri/src/features/git/components/status/git-status-panel.tsx b/windows/tauri/src/features/git/components/status/git-status-panel.tsx
index 2e1a537c2..3434e9bee 100644
--- a/windows/tauri/src/features/git/components/status/git-status-panel.tsx
+++ b/windows/tauri/src/features/git/components/status/git-status-panel.tsx
@@ -72,8 +72,7 @@ import { showGitPatchDialog } from "../../services/git-patch-dialog-service";
interface GitStatusPanelProps {
files: GitFile[];
- commitSelectedPaths: ReadonlySet;
- onCommitSelectedPathsChange: (paths: Set) => void;
+ repositoryCount?: number;
collapsedFolders: ReadonlySet;
onCollapsedFoldersChange: (folders: Set) => void;
collapsedSections: ReadonlySet;
@@ -88,6 +87,7 @@ interface GitStatusPanelProps {
onShowStashDiffPicker?: () => void;
onRefresh?: () => void;
onStagingRefresh: () => Promise;
+ onStagingPendingChange?: (pending: boolean) => void;
repoPath?: string;
}
@@ -103,10 +103,11 @@ interface GitStatusSelectionEntry {
files: GitFile[];
}
-type StatusSection = "tracked" | "untracked";
+type StatusSection = string;
type GitStatusDiffScope = "all" | "unstaged" | "staged";
type GitStatusVirtualRow =
+ | { kind: "repository"; key: string; repoPath: string; count: number }
| {
kind: "section";
key: string;
@@ -166,9 +167,9 @@ function groupGitFilesByRepository(
}
function getRepoRelativePaths(files: readonly GitFile[]): string[] {
- return [
- ...new Set(files.map(getGitFileRepositoryRelativePath).filter(Boolean)),
- ].sort((left, right) => left.localeCompare(right));
+ return [...new Set(files.map(getGitFileRepositoryRelativePath).filter(Boolean))].sort(
+ (left, right) => left.localeCompare(right),
+ );
}
function logStagingFailure(
@@ -205,8 +206,7 @@ function logStagingFailure(
const GitStatusPanel = ({
files,
- commitSelectedPaths,
- onCommitSelectedPathsChange,
+ repositoryCount = 1,
collapsedFolders,
onCollapsedFoldersChange,
collapsedSections,
@@ -221,6 +221,7 @@ const GitStatusPanel = ({
onShowStashDiffPicker,
onRefresh,
onStagingRefresh,
+ onStagingPendingChange,
repoPath,
}: GitStatusPanelProps) => {
const { t } = useTranslation();
@@ -236,6 +237,10 @@ const GitStatusPanel = ({
const [optimisticStageMap, setOptimisticStageMap] = useState>({});
const stagingOperationsRef = useRef(new Map>());
const [stagePendingPaths, setStagePendingPaths] = useState>(new Set());
+ useEffect(() => {
+ onStagingPendingChange?.(stagePendingPaths.size > 0);
+ return () => onStagingPendingChange?.(false);
+ }, [stagePendingPaths, onStagingPendingChange]);
const [selectedEntryIds, setSelectedEntryIds] = useState>(new Set());
const [stashModal, setStashModal] = useState<{
@@ -250,9 +255,11 @@ const GitStatusPanel = ({
});
useEffect(() => {
- setOptimisticStageMap((current) => Object.fromEntries(
- Object.entries(current).filter(([path]) => stagingOperationsRef.current.has(path)),
- ));
+ setOptimisticStageMap((current) =>
+ Object.fromEntries(
+ Object.entries(current).filter(([path]) => stagingOperationsRef.current.has(path)),
+ ),
+ );
}, [files]);
const displayFiles = useMemo(() => {
@@ -272,18 +279,36 @@ const GitStatusPanel = ({
hasUnstagedDiffableFiles,
visibleFiles,
displayFileByPath,
- trackedFiles,
- untrackedFiles,
- groupedTrackedFiles,
- groupedUntrackedFiles,
} = useMemo(() => buildGitStatusPresentation(displayFiles), [displayFiles]);
- const trackedFolderTree = useMemo(
- () => (gitChangesFolderView ? buildGitFolderTree(trackedFiles) : null),
- [gitChangesFolderView, trackedFiles],
+ const repositoryGroups = useMemo(
+ () => groupGitFilesByRepository(visibleFiles, repoPath),
+ [visibleFiles, repoPath],
+ );
+ const sections = useMemo(
+ () =>
+ repositoryGroups.flatMap((group) => {
+ const presentation = buildGitStatusPresentation(group.files);
+ return ["tracked", "untracked"].map((kind) => {
+ const sectionFiles =
+ kind === "tracked" ? presentation.trackedFiles : presentation.untrackedFiles;
+ return {
+ id: `${group.repoPath}:${kind}`,
+ repoPath: group.repoPath,
+ kind,
+ files: sectionFiles,
+ grouped:
+ kind === "tracked"
+ ? presentation.groupedTrackedFiles
+ : presentation.groupedUntrackedFiles,
+ tree: gitChangesFolderView ? buildGitFolderTree(sectionFiles, true) : null,
+ };
+ });
+ }),
+ [repositoryGroups, gitChangesFolderView],
);
- const untrackedFolderTree = useMemo(
- () => (gitChangesFolderView ? buildGitFolderTree(untrackedFiles) : null),
- [gitChangesFolderView, untrackedFiles],
+ const sectionById = useMemo(
+ () => new Map(sections.map((section) => [section.id, section])),
+ [sections],
);
const entryById = useMemo(() => {
const entries = new Map();
@@ -294,7 +319,7 @@ const GitStatusPanel = ({
id,
kind: "file",
path: file.path,
- filePaths: resolveGitFileMutationPaths([file]),
+ filePaths: [file.path],
files: [file],
});
}
@@ -317,7 +342,7 @@ const GitStatusPanel = ({
id,
kind: "folder",
path: branch.path,
- filePaths: resolveGitFileMutationPaths(files),
+ filePaths: files.map((file) => file.path),
files,
});
branch.children.forEach(registerNode);
@@ -326,10 +351,9 @@ const GitStatusPanel = ({
tree.nodes.forEach(registerNode);
};
- registerFolders(trackedFolderTree, "tracked");
- registerFolders(untrackedFolderTree, "untracked");
+ for (const section of sections) registerFolders(section.tree, section.id);
return entries;
- }, [displayFileByPath, trackedFolderTree, untrackedFolderTree, visibleFiles]);
+ }, [displayFileByPath, sections, visibleFiles]);
const statusRows = useMemo(() => {
const rows: GitStatusVirtualRow[] = [];
@@ -403,20 +427,25 @@ const GitStatusPanel = ({
}
};
- appendSection("tracked", trackedFiles.length, trackedFolderTree, groupedTrackedFiles);
- appendSection("untracked", untrackedFiles.length, untrackedFolderTree, groupedUntrackedFiles);
+ for (const group of repositoryGroups) {
+ const key = `repository:${group.repoPath}`;
+ if (repositoryCount > 1 || repositoryGroups.length > 1) {
+ rows.push({ kind: "repository", key, repoPath: group.repoPath, count: group.files.length });
+ if (collapsedSections.has(key)) continue;
+ }
+ for (const section of sections.filter((section) => section.repoPath === group.repoPath)) {
+ appendSection(section.id, section.files.length, section.tree, section.grouped);
+ }
+ }
return rows;
}, [
collapsedFolders,
collapsedSections,
fileTreePresentation.compactFolders,
gitChangesFolderView,
- groupedTrackedFiles,
- groupedUntrackedFiles,
- trackedFiles.length,
- trackedFolderTree,
- untrackedFiles.length,
- untrackedFolderTree,
+ repositoryGroups,
+ repositoryCount,
+ sections,
]);
const statusVirtualizer = useVirtualizer({
@@ -424,7 +453,8 @@ const GitStatusPanel = ({
getScrollElement: () => statusViewportRef.current,
estimateSize: (index) => {
const row = statusRows[index];
- if (row?.kind === "section") return GIT_STATUS_SECTION_HEADER_HEIGHT;
+ if (row?.kind === "section" || row?.kind === "repository")
+ return GIT_STATUS_SECTION_HEADER_HEIGHT;
if (row?.kind === "spacer") return row.size;
return fileTreePresentation.rowHeight;
},
@@ -449,7 +479,11 @@ const GitStatusPanel = ({
});
};
- const handleSetFilesStaged = async (filesToStage: GitFile[], staged: boolean): Promise => {
+ const handleSetFilesStaged = async (
+ filesToStage: GitFile[],
+ staged: boolean,
+ ): Promise => {
+ filesToStage = filesToStage.filter((file) => file.canToggleStaging !== false);
const repositoryGroups = groupGitFilesByRepository(filesToStage, repoPath);
if (repositoryGroups.length === 0) return false;
const displayFilePaths = [...new Set(filesToStage.map((file) => file.path))];
@@ -470,7 +504,8 @@ const GitStatusPanel = ({
);
for (const [repositoryIndex, result] of results.entries()) {
if (result.status === "rejected") {
- const message = result.reason instanceof Error ? result.reason.message : String(result.reason);
+ const message =
+ result.reason instanceof Error ? result.reason.message : String(result.reason);
logStagingFailure(
message,
staged,
@@ -487,7 +522,13 @@ const GitStatusPanel = ({
return results.every((result) => result.status === "fulfilled" && result.value);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
- logStagingFailure(message, staged, "refresh", displayFilePaths.length, repositoryGroups.length);
+ logStagingFailure(
+ message,
+ staged,
+ "refresh",
+ displayFilePaths.length,
+ repositoryGroups.length,
+ );
toast.error(t("git.operationError", { error: message }));
return false;
} finally {
@@ -498,9 +539,9 @@ const GitStatusPanel = ({
}
const pendingPaths = new Set(stagingOperationsRef.current.keys());
setStagePendingPaths(pendingPaths);
- setOptimisticStageMap((current) => Object.fromEntries(
- Object.entries(current).filter(([path]) => pendingPaths.has(path)),
- ));
+ setOptimisticStageMap((current) =>
+ Object.fromEntries(Object.entries(current).filter(([path]) => pendingPaths.has(path))),
+ );
}
};
@@ -511,13 +552,18 @@ const GitStatusPanel = ({
...new Set(entries.flatMap((entry) => entry.filePaths)),
];
- const handleCommitEntries = (entries: GitStatusSelectionEntry[]) => {
+ const handleCommitEntries = async (entries: GitStatusSelectionEntry[]) => {
const filePaths = [
...new Set(entries.flatMap((entry) => entry.files.map((file) => file.path))),
];
if (filePaths.length === 0) return;
- onCommitSelectedPathsChange(new Set(filePaths));
- onCommitSelection?.(filePaths);
+ if (
+ await handleSetFilesStaged(
+ entries.flatMap((entry) => entry.files),
+ true,
+ )
+ )
+ onCommitSelection?.(filePaths);
};
const handleRollbackEntries = async (entries: GitStatusSelectionEntry[]) => {
@@ -529,10 +575,13 @@ const GitStatusPanel = ({
if (
confirmBeforeDiscard &&
- !(await showConfirmDialog(t("git.rollbackPathsConfirm", { count: displayTrackedFilePaths.length }), {
- title: t("git.rollback"),
- confirmLabel: t("git.rollback"),
- }))
+ !(await showConfirmDialog(
+ t("git.rollbackPathsConfirm", { count: displayTrackedFilePaths.length }),
+ {
+ title: t("git.rollback"),
+ confirmLabel: t("git.rollback"),
+ },
+ ))
) {
return;
}
@@ -575,13 +624,19 @@ const GitStatusPanel = ({
setIsLoading(true);
try {
- const result = await deleteGitStatusPaths(filePaths, async (filePath) => {
- const file = entries.flatMap((entry) => entry.files).find((candidate) => candidate.path === filePath);
- const fileRepoPath = file ? getGitFileRepositoryPath(file, repoPath) : repoPath;
- const relativePath = file ? getGitFileRepositoryRelativePath(file) : filePath;
- if (!fileRepoPath) throw new Error("Missing Git repository path for delete operation");
- return deleteFile(joinPath(fileRepoPath, relativePath));
- }, async () => onRefresh?.());
+ const result = await deleteGitStatusPaths(
+ filePaths,
+ async (filePath) => {
+ const file = entries
+ .flatMap((entry) => entry.files)
+ .find((candidate) => candidate.path === filePath);
+ const fileRepoPath = file ? getGitFileRepositoryPath(file, repoPath) : repoPath;
+ const relativePath = file ? getGitFileRepositoryRelativePath(file) : filePath;
+ if (!fileRepoPath) throw new Error("Missing Git repository path for delete operation");
+ return deleteFile(joinPath(fileRepoPath, relativePath));
+ },
+ async () => onRefresh?.(),
+ );
for (const failure of result.failures) {
console.error(`Failed to delete source-control file ${failure.path}:`, failure.error);
}
@@ -687,16 +742,14 @@ const GitStatusPanel = ({
});
};
- const handleSetCommitPathsSelected = (filePaths: string[], selected: boolean) => {
- const next = new Set(commitSelectedPaths);
- for (const filePath of filePaths) {
- if (selected) {
- next.add(filePath);
- } else {
- next.delete(filePath);
- }
- }
- onCommitSelectedPathsChange(next);
+ const handleSetCommitPathsSelected = (filePaths: string[], staged: boolean) => {
+ void handleSetFilesStaged(
+ filePaths.flatMap((path) => {
+ const file = displayFileByPath.get(path);
+ return file ? [file] : [];
+ }),
+ staged,
+ );
};
const handleStashAllUnstaged = async () => {
@@ -720,7 +773,8 @@ const GitStatusPanel = ({
if (stashModal.filePaths?.length) {
await createStash(
stashModal.repoPath ?? repoPath,
- message || t(stashModal.type === "all" ? "git.stashAllUnstagedChanges" : "git.stashSelectedDefault"),
+ message ||
+ t(stashModal.type === "all" ? "git.stashAllUnstagedChanges" : "git.stashSelectedDefault"),
stashModal.includeUntracked,
stashModal.filePaths,
);
@@ -855,10 +909,42 @@ const GitStatusPanel = ({
const renderStatusRow = (row: GitStatusVirtualRow) => {
if (row.kind === "spacer") return null;
+ if (row.kind === "repository") {
+ const groupFiles =
+ repositoryGroups.find((group) => group.repoPath === row.repoPath)?.files ?? [];
+ const eligible = groupFiles.filter((file) => file.canToggleStaging !== false);
+ const checked = eligible.length > 0 && eligible.every((file) => file.staged);
+ return (
+
+ toggleSectionCollapsed(row.key)}
+ className="h-full min-w-0 flex-1"
+ title={row.repoPath}
+ >
+ {row.repoPath}
+
+ void handleSetFilesStaged(eligible, staged)}
+ disabled={
+ isLoading ||
+ eligible.length === 0 ||
+ groupFiles.some((file) => stagePendingPaths.has(file.path))
+ }
+ aria-label={t(checked ? "git.excludeFolderFromCommit" : "git.includeFolderInCommit", {
+ name: row.repoPath,
+ })}
+ />
+
+ );
+ }
if (row.kind === "section") {
return renderSectionHeader(
row.section,
- t(row.section === "tracked" ? "git.tracked" : "git.untracked"),
+ t(sectionById.get(row.section)?.kind === "tracked" ? "git.tracked" : "git.untracked"),
row.count,
);
}
@@ -877,9 +963,11 @@ const GitStatusPanel = ({
}
}}
onContextMenu={(event) => handleContextMenu(event, entry)}
- checked={commitSelectedPaths.has(row.file.path)}
+ checked={row.file.staged}
onCheckedChange={(checked) => handleSetCommitPathsSelected([row.file.path], checked)}
- disabled={isLoading}
+ disabled={
+ isLoading || row.file.canToggleStaging === false || stagePendingPaths.has(row.file.path)
+ }
onStagedChange={(staged) => void handleSetFilesStaged([row.file], staged)}
stagePending={stagePendingPaths.has(row.file.path)}
showDirectory={row.showDirectory}
@@ -894,13 +982,13 @@ const GitStatusPanel = ({
);
}
- const tree = row.section === "tracked" ? trackedFolderTree : untrackedFolderTree;
+ const tree = sectionById.get(row.section)?.tree;
const folderState = tree?.folderStateById.get(row.branch.id);
const entry = entryById.get(getFolderEntryId(row.section, row.branch.path));
if (!folderState || !entry) return null;
const isCollapsed = collapsedFolders.has(`${row.section}:${row.branch.path}`);
- const isChecked = folderState.descendantFilePaths.every((filePath) =>
- commitSelectedPaths.has(filePath),
+ const isChecked = folderState.descendantFilePaths.every(
+ (filePath) => displayFileByPath.get(filePath)?.staged,
);
return (
@@ -947,7 +1035,11 @@ const GitStatusPanel = ({
onCheckedChange={(checked) =>
handleSetCommitPathsSelected(folderState.descendantFilePaths, checked)
}
- disabled={folderState.descendantFilePaths.length === 0}
+ disabled={
+ isLoading ||
+ entry.files.every((file) => file.canToggleStaging === false) ||
+ entry.files.some((file) => stagePendingPaths.has(file.path))
+ }
aria-label={
isChecked
? t("git.excludeFolderFromCommit", { name: row.label })
@@ -955,12 +1047,13 @@ const GitStatusPanel = ({
}
/>
}
- draggable={!!repoPath}
+ draggable={!!sectionById.get(row.section)?.repoPath}
onDragStart={(event) => {
- if (!repoPath) return;
+ const owner = sectionById.get(row.section)?.repoPath;
+ if (!owner) return;
writeSidebarResourceDragData(event.dataTransfer, {
type: "file",
- path: `${repoPath}/${row.branch.path}`,
+ path: joinPath(owner, row.branch.path),
name: row.branch.name,
isDir: true,
});
@@ -1247,8 +1340,7 @@ const GitStatusPanel = ({
label: getStageActionLabel(false),
icon: ,
disabled: isLoading,
- onClick: () =>
- void handleSetFilesStaged(contextMenuUnstagedFiles, true),
+ onClick: () => void handleSetFilesStaged(contextMenuUnstagedFiles, true),
},
]
: []),
@@ -1259,26 +1351,33 @@ const GitStatusPanel = ({
label: getStageActionLabel(true),
icon: ,
disabled: isLoading,
- onClick: () =>
- void handleSetFilesStaged(contextMenuStagedFiles, false),
+ onClick: () => void handleSetFilesStaged(contextMenuStagedFiles, false),
},
]
: []),
{
id: "create-patch-selection",
- label: contextMenuRepositories.length === 1 ? t("git.patch.create") : t("git.patch.singleRepository"),
+ label:
+ contextMenuRepositories.length === 1
+ ? t("git.patch.create")
+ : t("git.patch.singleRepository"),
icon: ,
disabled: isLoading || contextMenuRepositories.length !== 1,
onClick: () => {
const repository = contextMenuRepositories[0];
- if (repository) void showGitPatchDialog(repository.repoPath, { mode: "export", paths: getRepoRelativePaths(repository.files) });
+ if (repository)
+ void showGitPatchDialog(repository.repoPath, {
+ mode: "export",
+ paths: getRepoRelativePaths(repository.files),
+ });
},
},
{
id: "commit-selection",
label: t("git.commit"),
icon: ,
- disabled: contextMenuEntries.length === 0 || isLoading || stagePendingPaths.size > 0,
+ disabled:
+ contextMenuEntries.length === 0 || isLoading || stagePendingPaths.size > 0,
onClick: () => void handleCommitEntries(contextMenuEntries),
},
{
@@ -1304,12 +1403,7 @@ const GitStatusPanel = ({
if (contextMenuTarget) {
const file = contextMenuTarget.files[0];
const isDirectory = contextMenuTarget.kind === "folder";
- const prefixLength = file?.repositoryRelativePath
- ? file.path.length - file.repositoryRelativePath.length
- : 0;
- const path = isDirectory
- ? contextMenuTarget.path.slice(prefixLength)
- : contextMenuTarget.path;
+ const path = contextMenuTarget.path;
onOpenPath?.(path, isDirectory, file?.repositoryPath);
}
},
@@ -1318,7 +1412,10 @@ const GitStatusPanel = ({
id: "delete-selection",
label: t("git.delete"),
icon: ,
- disabled: contextMenuDeletionPaths.length === 0 || isLoading || stagePendingPaths.size > 0,
+ disabled:
+ contextMenuDeletionPaths.length === 0 ||
+ isLoading ||
+ stagePendingPaths.size > 0,
className: "text-destructive",
onClick: () => void handleDeleteEntries(contextMenuEntries),
},
@@ -1361,7 +1458,8 @@ const GitStatusPanel = ({
id: "stash-selection",
label: t("git.stash"),
icon: ,
- disabled: contextMenuEntries.length === 0 || isLoading || stagePendingPaths.size > 0,
+ disabled:
+ contextMenuEntries.length === 0 || isLoading || stagePendingPaths.size > 0,
onClick: () => handleStashEntries(contextMenuEntries),
},
]
diff --git a/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx
new file mode 100644
index 000000000..0c21afcb8
--- /dev/null
+++ b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import * as virtual from "@tanstack/react-virtual";
+import * as statusApi from "../../api/git-status-api";
+import { installHappyDom } from "@/test-utils/happy-dom";
+import { LocaleProvider } from "@/i18n/locale-provider";
+import GitStatusPanel from "./git-status-panel";
+import type { GitFile } from "../../types/git.types";
+
+let restoreDom: () => void;
+let root: Root;
+let container: HTMLDivElement;
+let staging: ReturnType>;
+let virtualizer: ReturnType>;
+const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
+let previousAct: boolean | undefined;
+beforeEach(() => {
+ restoreDom = installHappyDom();
+ previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT;
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ staging = spyOn(statusApi, "setFilesStaged").mockResolvedValue(true);
+ // Expose visible rows without relying on browser layout measurements in happy-dom.
+ virtualizer = spyOn(virtual, "useVirtualizer").mockImplementation(((
+ options: Parameters[0],
+ ) => ({
+ getVirtualItems: () =>
+ Array.from({ length: options.count }, (_, index) => ({ index, size: 32, start: index * 32 })),
+ getTotalSize: () => options.count * 32,
+ scrollToIndex: () => {},
+ })) as unknown as typeof virtual.useVirtualizer);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ staging.mockRestore();
+ virtualizer.mockRestore();
+ container.remove();
+ restoreDom();
+ if (previousAct === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT;
+ else actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct;
+});
+const render = async (files: GitFile[]) => {
+ await act(async () =>
+ root.render(
+
+ {}}
+ collapsedSections={new Set()}
+ onCollapsedSectionsChange={() => {}}
+ onStagingRefresh={async () => {}}
+ />
+ ,
+ ),
+ );
+};
+const file = (repository: string, name = "hello.ts"): GitFile => ({
+ path: `${repository}/src/${name}`,
+ repositoryPath: `C:/workspace/${repository}`,
+ repositoryRelativePath: `src/${name}`,
+ status: "modified",
+ staged: false,
+ canToggleStaging: true,
+});
+
+test("file checkboxes stage in their owner repository, independently of the active root", async () => {
+ await render([file("A", "a.ts"), file("B", "b.ts")]);
+ expect(container.textContent).toContain("C:/workspace/A");
+ expect(container.textContent).toContain("C:/workspace/B");
+ const checkbox = container.querySelector('[aria-label="Include b.ts in commit"]');
+ expect(checkbox).not.toBeNull();
+ await act(async () => checkbox!.click());
+ expect(staging).toHaveBeenCalledWith("C:/workspace/B", ["src/b.ts"], true);
+});
+
+test("keeps the repository header with one changed root and excludes dirty-only submodule pointers", async () => {
+ await render([file("B"), { ...file("B", "child"), canToggleStaging: false }]);
+ expect(container.textContent).toContain("C:/workspace/B");
+ const dirty = container.querySelector('[aria-label="Include child in commit"]');
+ expect(dirty?.hasAttribute("disabled") || dirty?.getAttribute("aria-disabled") === "true").toBe(
+ true,
+ );
+ const headerCheckbox = container.querySelector(
+ '[aria-label="Include folder C:/workspace/B in commit"]',
+ );
+ expect(headerCheckbox).not.toBeNull();
+ await act(async () => headerCheckbox!.click());
+ expect(staging).toHaveBeenCalledWith("C:/workspace/B", ["src/hello.ts"], true);
+});
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 535d8aefa..a043264bd 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
@@ -18,12 +18,7 @@ import {
type WorkingTreeDiffScope,
} from "../services/working-tree-diff-loader";
import type { MultiFileDiff } from "../types/git-diff.types";
-import type {
- GitCommit,
- GitDiff,
- GitFile,
- GitReference,
-} from "../types/git.types";
+import type { GitCommit, GitDiff, GitFile, GitReference } from "../types/git.types";
import { mapGitReadsInBatches } from "../utils/git-async-batch";
import { aggregateSelectedCommitDiffs } from "../utils/git-commit-selection-diff";
import {
@@ -31,14 +26,8 @@ import {
getGitFileRepositoryPath,
getGitFileRepositoryRelativePath,
} from "../utils/git-status-selection";
-import {
- createRequestGeneration,
- type RequestGeneration,
-} from "../utils/request-generation";
-import {
- createCommitDiffBuffer,
- createMultiFileDiff,
-} from "../utils/multi-file-diff";
+import { createRequestGeneration, type RequestGeneration } from "../utils/request-generation";
+import { createCommitDiffBuffer, createMultiFileDiff } from "../utils/multi-file-diff";
import { createSingleFileWorkingTreeDiff } from "../utils/working-tree-multi-diff";
const WORKING_TREE_TITLES: Record = {
@@ -61,22 +50,10 @@ function openDiffBuffer(
activateMainEditorPane();
return useBufferStore
.getState()
- .actions.openBuffer(
- virtualPath,
- displayName,
- "",
- false,
- undefined,
- true,
- true,
- diffData,
- );
+ .actions.openBuffer(virtualPath, displayName, "", false, undefined, true, true, diffData);
}
-function normalizeDisplayedFilePath(
- filePath: string,
- side: "old" | "new",
-): string {
+function normalizeDisplayedFilePath(filePath: string, side: "old" | "new"): string {
let actualFilePath = filePath;
if (filePath.includes(" -> ")) {
const [oldPath, newPath] = filePath.split(" -> ");
@@ -84,9 +61,7 @@ function normalizeDisplayedFilePath(
}
const trimmed = actualFilePath.trim();
- return trimmed.startsWith('"') && trimmed.endsWith('"')
- ? trimmed.slice(1, -1)
- : trimmed;
+ return trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
}
export function useGitDiffActions({
@@ -102,10 +77,7 @@ export function useGitDiffActions({
activeRepoPath: string | null;
onFileSelect?: (path: string, isDir: boolean) => void;
gitFileByPath: Map;
- workingTreeDiffEntriesByScope: Record<
- WorkingTreeDiffScope,
- WorkingTreeDiffEntry[]
- >;
+ workingTreeDiffEntriesByScope: Record;
commitByHash: Map;
currentBranch?: string;
currentReference?: GitReference;
@@ -154,17 +126,14 @@ export function useGitDiffActions({
const requestId = latestFileDiffRequest.begin();
try {
- const actualFilePath = normalizeDisplayedFilePath(
- filePath,
- staged ? "new" : "old",
- );
+ const actualFilePath = normalizeDisplayedFilePath(filePath, staged ? "new" : "old");
const file = gitFileByPath.get(actualFilePath);
if (file) {
const fileKey = `${staged ? "staged" : "unstaged"}:${actualFilePath}`;
const fileRepoPath = getGitFileRepositoryPath(file, activeRepoPath) ?? activeRepoPath;
const relativePath = getGitFileRepositoryRelativePath(file);
const originalRelativePath = getGitFileOriginalRepositoryRelativePath(file);
- const title = t(WORKING_TREE_TITLES.all);
+ const title = t(staged ? WORKING_TREE_TITLES.staged : WORKING_TREE_TITLES.all);
const untracked = file.status === "untracked";
const workingTreeTargets: MultiFileDiff["workingTreeTargets"] = {
[fileKey]: {
@@ -172,6 +141,7 @@ export function useGitDiffActions({
filePath: relativePath,
...(originalRelativePath ? { originalPath: originalRelativePath } : {}),
untracked,
+ ...(staged ? { staged: true } : {}),
},
};
const loadingDiff: MultiFileDiff = {
@@ -192,18 +162,16 @@ export function useGitDiffActions({
label: t("git.indexing"),
},
};
- const bufferId = openDiffBuffer(
- "diff://working-tree/all-files",
- title,
- loadingDiff,
- );
+ const bufferId = openDiffBuffer("diff://working-tree/all-files", title, loadingDiff);
void (async () => {
- const diff = await getWorkingTreePathDiff(
- fileRepoPath,
- relativePath,
- untracked,
- originalRelativePath,
- );
+ const diff = staged
+ ? await getFileDiff(fileRepoPath, relativePath, true)
+ : await getWorkingTreePathDiff(
+ fileRepoPath,
+ relativePath,
+ untracked,
+ originalRelativePath,
+ );
if (
!latestFileDiffRequest.isCurrent(requestId) ||
activeRepoPathRef.current !== activeRepoPath
@@ -217,8 +185,7 @@ export function useGitDiffActions({
indexingLabel: t("git.indexing"),
diffEntries: [],
initialDiffs:
- diff &&
- (diff.lines.length > 0 || diff.is_image || diff.is_binary)
+ diff && (diff.lines.length > 0 || diff.is_image || diff.is_binary)
? [{ fileKey, diff }]
: [],
initialProcessed: 1,
@@ -236,10 +203,7 @@ export function useGitDiffActions({
) {
return;
}
- if (
- !diff ||
- (diff.lines.length === 0 && !diff.is_image && !diff.is_binary)
- ) {
+ if (!diff || (diff.lines.length === 0 && !diff.is_image && !diff.is_binary)) {
await openOriginalFile(actualFilePath);
return;
}
@@ -252,11 +216,7 @@ export function useGitDiffActions({
title: t(WORKING_TREE_TITLES.all),
});
- openDiffBuffer(
- "diff://working-tree/all-files",
- t(WORKING_TREE_TITLES.all),
- selectedDiff,
- );
+ openDiffBuffer("diff://working-tree/all-files", t(WORKING_TREE_TITLES.all), selectedDiff);
} catch (error) {
if (
!latestFileDiffRequest.isCurrent(requestId) ||
@@ -289,10 +249,7 @@ export function useGitDiffActions({
)
: workingTreeDiffEntriesByScope[scope];
if (diffEntries.length === 0) {
- await showAlertDialog(
- t(WORKING_TREE_EMPTY_LABELS[scope]),
- t("git.diff.title"),
- );
+ await showAlertDialog(t(WORKING_TREE_EMPTY_LABELS[scope]), t("git.diff.title"));
return;
}
@@ -313,11 +270,7 @@ export function useGitDiffActions({
label: t("git.indexing"),
},
};
- const bufferId = openDiffBuffer(
- `diff://working-tree/${scope}`,
- title,
- multiDiff,
- );
+ const bufferId = openDiffBuffer(`diff://working-tree/${scope}`, title, multiDiff);
void loadWorkingTreeDiffsProgressively({
repoPath: activeRepoPath,
@@ -456,10 +409,7 @@ export function useGitDiffActions({
})),
);
if (aggregate.diffs.length === 0) {
- await showAlertDialog(
- t("git.diff.noChangesInSelectedCommits"),
- t("git.diff.title"),
- );
+ await showAlertDialog(t("git.diff.noChangesInSelectedCommits"), t("git.diff.title"));
return;
}
@@ -502,10 +452,7 @@ export function useGitDiffActions({
try {
const diffs = await getStashDiff(activeRepoPath, stashIndex);
if (!diffs?.length) {
- await showAlertDialog(
- t("git.diff.noChangesInStash"),
- t("git.diff.title"),
- );
+ await showAlertDialog(t("git.diff.noChangesInStash"), t("git.diff.title"));
return;
}
@@ -580,8 +527,7 @@ export function useGitDiffActions({
const viewBranchDiff = useCallback(
async (baseBranch: GitReference | string) => {
const targetBranch = currentBranch ?? "HEAD";
- const baseName =
- typeof baseBranch === "string" ? baseBranch : baseBranch.fullName;
+ const baseName = typeof baseBranch === "string" ? baseBranch : baseBranch.fullName;
if (!activeRepoPath || !baseName || baseName === targetBranch) return;
const title = `${baseName}..${targetBranch}`;
@@ -589,11 +535,7 @@ export function useGitDiffActions({
try {
const diffs =
typeof baseBranch !== "string" && currentReference
- ? await getTypedReferenceDiff(
- activeRepoPath,
- baseBranch,
- currentReference,
- )
+ ? await getTypedReferenceDiff(activeRepoPath, baseBranch, currentReference)
: await getRefDiff(activeRepoPath, baseName, targetBranch);
if (!diffs?.length) {
await showAlertDialog(
@@ -645,8 +587,7 @@ export function useGitDiffActions({
);
return;
}
- const fullName =
- typeof reference === "string" ? reference : reference.fullName;
+ const fullName = typeof reference === "string" ? reference : reference.fullName;
const title = t("git.log.workingTreeComparisonTitle", {
branch: displayName,
});
@@ -661,10 +602,7 @@ export function useGitDiffActions({
}),
);
} catch (error) {
- console.error(
- "Error comparing reference with the working tree:",
- error,
- );
+ console.error("Error comparing reference with the working tree:", error);
await showAlertDialog(
t("git.log.workingTreeComparisonFailed", {
branch: displayName,
diff --git a/windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx b/windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx
new file mode 100644
index 000000000..d2cb97c0a
--- /dev/null
+++ b/windows/tauri/src/features/git/runtime/git-workspace-commit-host.test.tsx
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, expect, spyOn, test } from "bun:test";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { installHappyDom } from "@/test-utils/happy-dom";
+import { workspaceRuntimeRegistry as registry } from "@/features/workspace/runtime/workspace-runtime-registry";
+import { useWorkspaceCommitStore } from "../stores/git-workspace-commit.store";
+import { useGitStore } from "../stores/git.store";
+import { GitWorkspaceCommitHost } from "./git-workspace-commit-host";
+import fixture from "../../../../../../shared/fixtures/git/workspace-commit-workflow-v1.json";
+
+let restoreDom: () => void;
+let container: HTMLDivElement;
+let root: Root;
+const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
+let previousAct: boolean | undefined;
+beforeEach(() => {
+ registry.resetForTests();
+ registry.activateWorkspace({ id: "A", name: "A" }, "ready");
+ restoreDom = installHappyDom();
+ previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT;
+ actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+});
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ restoreDom();
+ registry.resetForTests();
+ if (previousAct === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT;
+ else actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct;
+});
+
+test("workbench host keeps a batch across panel rerenders and disposes it on workspace switch", async () => {
+ const workflow = useWorkspaceCommitStore.getStore("A").getState().workflow;
+ const dispose = spyOn(workflow, "dispose");
+ try {
+ await act(async () => root.render());
+ await act(async () => root.render());
+ expect(dispose).not.toHaveBeenCalled();
+ await act(async () => registry.activateWorkspace({ id: "B", name: "B" }, "ready"));
+ expect(dispose).toHaveBeenCalledTimes(1);
+ } finally {
+ dispose.mockRestore();
+ }
+});
+
+test.each(["commit", "new draft"])(
+ "completion clears only the unchanged originating draft (%s)",
+ async (message) => {
+ const owner = useWorkspaceCommitStore.getStore("A").getState();
+ owner.setDraftOwner("/workspace/A");
+ useGitStore
+ .getStore("A")
+ .getState()
+ .actions.updateSourceControlSession("/workspace/A", { commitMessage: message });
+ await act(async () => root.render());
+ await act(async () =>
+ owner.workflow.setState({
+ session: { ...fixture.preparation.session, finished: true, succeeded: true },
+ }),
+ );
+ expect(
+ useGitStore.getStore("A").getState().sourceControlSessions["/workspace/A"]?.commitMessage,
+ ).toBe(message === "commit" ? "" : message);
+ expect(useWorkspaceCommitStore.getStore("A").getState().draftOwner).toBeNull();
+ },
+);
diff --git a/windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx b/windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx
new file mode 100644
index 000000000..b2df4a59d
--- /dev/null
+++ b/windows/tauri/src/features/git/runtime/git-workspace-commit-host.tsx
@@ -0,0 +1,35 @@
+import { useEffect, useSyncExternalStore } from "react";
+import {
+ useActiveWorkspaceId,
+ useWorkspaceReady,
+} from "@/features/workspace/stores/create-workspace-scoped-store";
+import { useGitStore } from "../stores/git.store";
+import { useGitBlameStore } from "../stores/git-blame.store";
+import { useWorkspaceCommitStore } from "../stores/git-workspace-commit.store";
+
+/** Workbench lifetime: hiding the Git sidebar must not stop a workspace batch. */
+export function GitWorkspaceCommitHost() {
+ const workspaceId = useActiveWorkspaceId();
+ const ready = useWorkspaceReady(workspaceId);
+ const workflow = useWorkspaceCommitStore((state) => state.workflow);
+ const draftOwner = useWorkspaceCommitStore((state) => state.draftOwner);
+ const setDraftOwner = useWorkspaceCommitStore((state) => state.setDraftOwner);
+ const batch = useSyncExternalStore(workflow.subscribe, workflow.getState, workflow.getState);
+ useEffect(() => {
+ if (!ready || !batch.session?.succeeded || !draftOwner) return;
+ const git = useGitStore.getStore(workspaceId).getState();
+ // Read the live draft: typing during confirmation must never be lost.
+ if (
+ git.sourceControlSessions[draftOwner]?.commitMessage.trim() === batch.session.plan.message
+ ) {
+ git.actions.updateSourceControlSession(draftOwner, { commitMessage: "" });
+ }
+ useGitBlameStore.getStore(workspaceId).getState().actions.clearAllBlame();
+ setDraftOwner(null);
+ }, [batch.session, draftOwner, ready, setDraftOwner, workspaceId]);
+ useEffect(() => {
+ if (!ready) workflow.dispose();
+ return () => workflow.dispose();
+ }, [workflow, workspaceId, ready]);
+ return null;
+}
diff --git a/windows/tauri/src/features/git/services/ai-commit-context.test.ts b/windows/tauri/src/features/git/services/ai-commit-context.test.ts
index 1b4cb04b5..37bcb7345 100644
--- a/windows/tauri/src/features/git/services/ai-commit-context.test.ts
+++ b/windows/tauri/src/features/git/services/ai-commit-context.test.ts
@@ -16,7 +16,7 @@ const diff = (path: string, patch = "+change"): GitDiff => ({
lines: [],
raw_patch: patch,
});
-test("reads all selected paths including staged and unstaged worktree content", async () => {
+test("reads all selected staged paths", async () => {
const selected = Array.from({ length: 14 }, (_, i) => file(`file${i}`));
const paths: string[] = [];
const result = await collectCommitContext(
@@ -52,17 +52,19 @@ test("retains original rename path and owner repository", async () => {
);
expect(calls).toEqual([["repo", "new", false, "old"]]);
});
-test("rejects mixed repositories and unreadable paths without partial generation", async () => {
- await expect(
- collectCommitContext(
- "repo",
- [file("one"), { ...file("two"), repositoryPath: "other" }],
- new AbortController().signal,
- async () => {
- throw new Error("must not read");
- },
- ),
- ).rejects.toThrow("AI_COMMIT_MULTIPLE_REPOSITORIES");
+test("keeps files from multiple repositories distinct and rejects unreadable paths", async () => {
+ const calls: string[] = [];
+ const inputs = await collectCommitContext(
+ "repo",
+ [file("same"), { ...file("same"), repositoryPath: "other" }],
+ new AbortController().signal,
+ async (root, path) => {
+ calls.push(root);
+ return diff(path);
+ },
+ );
+ expect(calls.sort()).toEqual(["other", "repo"]);
+ expect(inputs.map((input) => input.path)).toEqual(["repository-2/same", "repository-1/same"]);
await expect(
collectCommitContext("repo", [file("one")], new AbortController().signal, async () => null),
).rejects.toThrow("AI_COMMIT_DIFF_FAILED");
diff --git a/windows/tauri/src/features/git/services/ai-commit-context.ts b/windows/tauri/src/features/git/services/ai-commit-context.ts
index 4768b108f..5dbb83911 100644
--- a/windows/tauri/src/features/git/services/ai-commit-context.ts
+++ b/windows/tauri/src/features/git/services/ai-commit-context.ts
@@ -41,7 +41,7 @@ export async function collectCommitContext(
const repositories = new Set(
files.map((file) => getGitFileRepositoryPath(file, repoPath) ?? repoPath),
);
- if (repositories.size > 1) throw new Error("AI_COMMIT_MULTIPLE_REPOSITORIES");
+ const repositoryIds = [...repositories].sort();
const ordered = [...files].sort((a, b) =>
getGitFileRepositoryRelativePath(a).localeCompare(getGitFileRepositoryRelativePath(b)),
);
@@ -84,7 +84,10 @@ export async function collectCommitContext(
? "\n[Diff truncated; do not infer omitted changes.]"
: "");
inputs[index] = {
- path,
+ path:
+ repositories.size > 1
+ ? `repository-${repositoryIds.indexOf(getGitFileRepositoryPath(file, repoPath) ?? repoPath) + 1}/${path}`
+ : path,
changeKind: file.status,
diff: diff.is_binary ? "" : text,
fingerprint,
diff --git a/windows/tauri/src/features/git/services/ai-commit-service.ts b/windows/tauri/src/features/git/services/ai-commit-service.ts
index b023e0e50..c1934aca4 100644
--- a/windows/tauri/src/features/git/services/ai-commit-service.ts
+++ b/windows/tauri/src/features/git/services/ai-commit-service.ts
@@ -1,11 +1,11 @@
import { invoke } from "@/platform/tauri-core";
import type { CommitAISettings, CommitDetection, CommitFileInput } from "../types/ai-commit";
-import { getWorkingTreePathDiff } from "../api/git-diff-api";
+import { getFileDiff } from "../api/git-diff-api";
import { collectCommitContext } from "./ai-commit-context";
import type { GitFile } from "../types/git.types";
export { commitSelectionKey } from "./ai-commit-context";
export const collectCommitFiles = (repo: string, files: GitFile[], signal: AbortSignal) =>
- collectCommitContext(repo, files, signal, getWorkingTreePathDiff);
+ collectCommitContext(repo, files, signal, (root, path) => getFileDiff(root, path, true));
type Translate = (key: string, values?: Record) => string;
export function commitAIError(error: unknown, t: Translate): string {
diff --git a/windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts b/windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts
new file mode 100644
index 000000000..44dfae0d3
--- /dev/null
+++ b/windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts
@@ -0,0 +1,193 @@
+import { describe, expect, mock, test } from "bun:test";
+import fixture from "../../../../../../shared/fixtures/git/workspace-commit-workflow-v1.json";
+import {
+ createWorkspaceCommitWorkflow,
+ type WorkspaceCommitPort,
+} from "./git-workspace-commit-workflow";
+import type {
+ WorkspaceCommitPreparation,
+ WorkspaceCommitSession,
+} from "../types/git-workspace-commit.types";
+
+const preparation = (): WorkspaceCommitPreparation => structuredClone(fixture.preparation);
+const completed = (): WorkspaceCommitSession => ({
+ ...preparation().session,
+ finished: true,
+ succeeded: true,
+ canRetry: false,
+});
+const setup = () => {
+ const prepare = mock(async () => preparation());
+ const step = mock(async () => completed());
+ const cancel = mock(async () => {});
+ return {
+ prepare,
+ step,
+ cancel,
+ workflow: createWorkspaceCommitWorkflow({ prepare, step, cancel }),
+ };
+};
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+
+describe("workspace commit native continuation adapter", () => {
+ test("requires review and returns the shared plan unchanged before executing steps", async () => {
+ const { workflow, prepare, step } = setup();
+ await workflow.prepare(fixture.request);
+ expect(workflow.getState().review?.preparation).toEqual(fixture.preparation);
+ expect(step).not.toHaveBeenCalled();
+ await workflow.confirm(fixture.request.repositories);
+ expect(prepare.mock.calls[1]![0]).toEqual({
+ ...fixture.request,
+ reviewed: fixture.preparation.session.plan,
+ });
+ expect(step.mock.calls[0]![0]).toEqual(fixture.preparation.session);
+ expect(workflow.getState().session?.succeeded).toBe(true);
+ const ids = [...prepare.mock.calls, ...step.mock.calls].map((call) => call[1]);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ test("a changed Core plan requires another confirmation without executing the old one", async () => {
+ const { workflow, prepare, step } = setup();
+ await workflow.prepare(fixture.request);
+ const changed = preparation();
+ changed.reviewChanged = true;
+ changed.session.plan.states["A/B"]!.stagedPaths.push("new.ts");
+ prepare.mockResolvedValueOnce(changed);
+ await workflow.confirm(fixture.request.repositories);
+ expect(workflow.getState().review?.preparation).toEqual(changed);
+ expect(step).not.toHaveBeenCalled();
+ await workflow.confirm(fixture.request.repositories);
+ expect(prepare.mock.calls[2]![0].reviewed).toEqual(changed.session.plan);
+ expect(step).toHaveBeenCalledTimes(1);
+ });
+
+ test("confirmation includes newly discovered roots and presents Core's replacement plan", async () => {
+ const { workflow, prepare, step } = setup();
+ await workflow.prepare(fixture.request);
+ const repositories = [...fixture.request.repositories, { id: "C", root: "/workspace/C" }];
+ const changed = preparation();
+ changed.session.plan.repositories = repositories;
+ changed.reviewChanged = true;
+ prepare.mockResolvedValueOnce(changed);
+ await workflow.confirm(repositories);
+ expect(prepare.mock.calls[1]![0].repositories).toEqual(repositories);
+ expect(workflow.getState().review?.preparation).toEqual(changed);
+ expect(step).not.toHaveBeenCalled();
+ });
+
+ test("parent opt-out regenerates a review and retry forwards the prior session", async () => {
+ const { workflow, prepare, step } = setup();
+ const previous = { ...preparation().session, finished: true, canRetry: true };
+ await workflow.prepare({ ...fixture.request, previous });
+ await workflow.setIncludeParentReferences(false, fixture.request.repositories);
+ expect(prepare.mock.calls[1]![0]).toEqual({
+ ...fixture.request,
+ previous,
+ includeParentReferences: false,
+ reviewed: undefined,
+ });
+ expect(step).not.toHaveBeenCalled();
+ });
+
+ test("drives each Core result unchanged and retains recovery after a transport failure", async () => {
+ const { workflow, step } = setup();
+ await workflow.prepare(fixture.request);
+ const progress = preparation().session;
+ progress.cursor = 1;
+ progress.results["A/B"] = {
+ committed: true,
+ pushed: false,
+ status: "committedPushPending",
+ detail: "",
+ };
+ step.mockResolvedValueOnce(progress).mockRejectedValueOnce(new Error("bridge unavailable"));
+ await workflow.confirm(fixture.request.repositories);
+ expect(step.mock.calls[1]![0]).toEqual(progress);
+ expect(workflow.getState().session).toEqual(progress);
+ expect(workflow.getState().error).toBe("bridge unavailable");
+ expect(workflow.getState().busy).toBe(false);
+ });
+
+ test("first step transport failure still exposes the initial recovery session", async () => {
+ const { workflow, step } = setup();
+ await workflow.prepare(fixture.request);
+ step.mockRejectedValueOnce(new Error("connection lost"));
+ await workflow.confirm(fixture.request.repositories);
+ expect(workflow.getState().session).toEqual(fixture.preparation.session);
+ workflow.dismiss();
+ expect(workflow.getState().session).toBeNull();
+ });
+
+ test("scope disposal cancels the request, preserves reconciled commit and starts no next step", async () => {
+ const { workflow, step, cancel } = setup();
+ const entered = deferred();
+ const release = deferred();
+ step.mockImplementationOnce(async () => {
+ entered.resolve();
+ return release.promise;
+ });
+ await workflow.prepare(fixture.request);
+ const running = workflow.confirm(fixture.request.repositories);
+ const progress = preparation().session;
+ progress.results["A/B"] = {
+ committed: true,
+ pushed: false,
+ status: "committedPushPending",
+ detail: "",
+ };
+ try {
+ await entered.promise;
+ workflow.dispose();
+ expect(cancel).toHaveBeenCalledWith(step.mock.calls[0]![1]);
+ } finally {
+ release.resolve(progress);
+ await running;
+ }
+ expect(step).toHaveBeenCalledTimes(1);
+ expect(workflow.getState().session).toEqual(progress);
+ expect(workflow.getState().busy).toBe(false);
+ }, 1000);
+
+ test("user cancellation leaves continuation decisions to Core so independent repositories can finish", async () => {
+ const { workflow, step } = setup();
+ const entered = deferred();
+ const release = deferred();
+ step.mockImplementationOnce(async () => {
+ entered.resolve();
+ return release.promise;
+ });
+ await workflow.prepare(fixture.request);
+ const running = workflow.confirm(fixture.request.repositories);
+ try {
+ await entered.promise;
+ workflow.cancel();
+ } finally {
+ release.resolve({ ...preparation().session, commandFailed: true });
+ await running;
+ }
+ expect(step).toHaveBeenCalledTimes(2);
+ expect(workflow.getState().session?.finished).toBe(true);
+ }, 1000);
+
+ test("scope disposal during preparation cannot open a stale review or write repositories", async () => {
+ const { workflow, prepare, step } = setup();
+ const release = deferred();
+ prepare.mockImplementationOnce(() => release.promise);
+ const running = workflow.prepare(fixture.request);
+ try {
+ workflow.dispose();
+ } finally {
+ release.resolve(preparation());
+ await running;
+ }
+ expect(workflow.getState().review).toBeNull();
+ expect(step).not.toHaveBeenCalled();
+ }, 1000);
+});
diff --git a/windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts b/windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts
new file mode 100644
index 000000000..0e2439089
--- /dev/null
+++ b/windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts
@@ -0,0 +1,131 @@
+import { createStore } from "zustand/vanilla";
+import type {
+ WorkspaceCommitPreparation,
+ WorkspaceCommitRequest,
+ WorkspaceCommitSession,
+ WorkspaceRepositoryBinding,
+} from "../types/git-workspace-commit.types";
+
+export interface WorkspaceCommitPort {
+ prepare: (
+ request: WorkspaceCommitRequest,
+ operationId: string,
+ ) => Promise;
+ step: (session: WorkspaceCommitSession, operationId: string) => Promise;
+ cancel: (operationId: string) => Promise;
+}
+interface Review {
+ request: WorkspaceCommitRequest;
+ preparation: WorkspaceCommitPreparation;
+}
+interface WorkflowState {
+ busy: boolean;
+ session: WorkspaceCommitSession | null;
+ review: Review | null;
+ error: string | null;
+}
+
+/** UI lifecycle only. Core owns dependency ordering, guards, outcomes and retry policy. */
+export function createWorkspaceCommitWorkflow(port: WorkspaceCommitPort) {
+ const store = createStore(() => ({
+ busy: false,
+ session: null,
+ review: null,
+ error: null,
+ }));
+ let stopped = false;
+ let operationId: string | null = null;
+
+ const invoke = async (operation: (id: string) => Promise): Promise => {
+ operationId = crypto.randomUUID();
+ try {
+ return await operation(operationId);
+ } finally {
+ operationId = null;
+ }
+ };
+ const execute = async (initial: WorkspaceCommitSession) => {
+ let session = initial;
+ // Publish before invoking native code: a lost reply must still offer recovery.
+ store.setState({ session, review: null });
+ while (!session.finished && !stopped) {
+ session = await invoke((id) => port.step(session, id));
+ // A cancelled step may have completed a commit. Keep Core's reconciled result.
+ store.setState({ session });
+ }
+ };
+ const prepare = async (
+ request: WorkspaceCommitRequest,
+ confirmed = false,
+ forceReview = false,
+ ) => {
+ if (store.getState().busy) return;
+ stopped = false;
+ store.setState({
+ busy: true,
+ error: null,
+ ...(store.getState().session?.succeeded ? { session: null } : {}),
+ });
+ try {
+ const preparation = await invoke((id) => port.prepare(request, id));
+ if (stopped) return;
+ if (
+ forceReview ||
+ preparation.reviewChanged ||
+ (!confirmed && preparation.requiresConfirmation)
+ ) {
+ store.setState({ review: { request, preparation } });
+ } else {
+ await execute(preparation.session);
+ }
+ } catch (error) {
+ if (!stopped)
+ store.setState({ error: error instanceof Error ? error.message : String(error) });
+ } finally {
+ store.setState({ busy: false });
+ }
+ };
+ return {
+ ...store,
+ prepare: (request: WorkspaceCommitRequest) => prepare(request),
+ confirm: (repositories: WorkspaceRepositoryBinding[]) => {
+ const review = store.getState().review;
+ if (!review) return Promise.resolve();
+ return prepare(
+ { ...review.request, repositories, reviewed: review.preparation.session.plan },
+ true,
+ );
+ },
+ setIncludeParentReferences: (
+ includeParentReferences: boolean,
+ repositories: WorkspaceRepositoryBinding[],
+ ) => {
+ const review = store.getState().review;
+ if (!review) return Promise.resolve();
+ return prepare(
+ { ...review.request, repositories, includeParentReferences, reviewed: undefined },
+ false,
+ true,
+ );
+ },
+ closeReview: () => {
+ if (!store.getState().busy) store.setState({ review: null, error: null });
+ },
+ dismiss: () => {
+ if (!store.getState().busy) store.setState({ session: null, review: null, error: null });
+ },
+ // User cancellation applies to the current command. Core can continue unrelated roots.
+ cancel: () => {
+ if (operationId)
+ void port.cancel(operationId).catch((error) => {
+ store.setState({ error: error instanceof Error ? error.message : String(error) });
+ });
+ },
+ // Leaving the workspace also prevents the adapter from dispatching another step.
+ dispose: () => {
+ stopped = true;
+ store.setState({ review: null });
+ if (operationId) void port.cancel(operationId).catch(() => {});
+ },
+ };
+}
diff --git a/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts b/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts
index fb139d328..56af5ef96 100644
--- a/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts
+++ b/windows/tauri/src/features/git/services/working-tree-diff-refresh.test.ts
@@ -202,7 +202,11 @@ describe("refreshWorkingTreeFileDiff", () => {
const statusFailure = await refreshWorkingTreeFileDiff(
{ bufferId: BUFFER_ID, fileKey: FILE_KEY },
- { buffers: buffers.port, loadStatus: async () => null, loadDiff: async () => diffWithLines(1) },
+ {
+ buffers: buffers.port,
+ loadStatus: async () => null,
+ loadDiff: async () => diffWithLines(1),
+ },
);
const diffFailure = await refreshWorkingTreeFileDiff(
{ bufferId: BUFFER_ID, fileKey: FILE_KEY },
@@ -242,7 +246,11 @@ describe("refreshWorkingTreeFileDiff", () => {
test("does not refresh diffs opened without a working-tree target", async () => {
const buffers = bufferPort(
- createSingleFileWorkingTreeDiff({ repoPath: REPO, fileKey: FILE_KEY, diff: diffWithLines(1) }),
+ createSingleFileWorkingTreeDiff({
+ repoPath: REPO,
+ fileKey: FILE_KEY,
+ diff: diffWithLines(1),
+ }),
);
let statusLoads = 0;
@@ -262,3 +270,21 @@ describe("refreshWorkingTreeFileDiff", () => {
expect(buffers.closed).toEqual([]);
});
});
+
+test("staged file review continues to read the index after a working-tree refresh", async () => {
+ const buffers = bufferPort(openedDiff({ ...TARGET, staged: true }));
+ const requests: unknown[][] = [];
+ await refreshWorkingTreeFileDiff(
+ { bufferId: BUFFER_ID, fileKey: FILE_KEY },
+ {
+ buffers: buffers.port,
+ loadStatus: async () => status([statusFile({ staged: true, worktree: true })]),
+ loadDiff: async (...args) => {
+ requests.push(args);
+ return diffWithLines(3);
+ },
+ },
+ );
+ expect(requests).toEqual([[REPO, "src/Main.java", false, undefined, true]]);
+ expect(buffers.current()?.workingTreeTargets?.[FILE_KEY]?.staged).toBe(true);
+});
diff --git a/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts b/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts
index 3333caeda..438caf999 100644
--- a/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts
+++ b/windows/tauri/src/features/git/services/working-tree-diff-refresh.ts
@@ -1,6 +1,6 @@
import equal from "fast-deep-equal";
import { normalizePath } from "@/utils/path-helpers";
-import { getWorkingTreePathDiff } from "../api/git-diff-api";
+import { getFileDiff, getWorkingTreePathDiff } from "../api/git-diff-api";
import { getGitStatus } from "../api/git-status-api";
import type { MultiFileDiff, WorkingTreeDiffTarget } from "../types/git-diff.types";
import type { GitDiff, GitFile, GitStatus } from "../types/git.types";
@@ -28,6 +28,7 @@ export interface WorkingTreeDiffRefreshDependencies {
filePath: string,
untracked: boolean,
originalPath?: string,
+ staged?: boolean,
) => Promise;
}
@@ -47,7 +48,7 @@ function hasRenderableDiff(diff: GitDiff): boolean {
/**
* Reloads one working-tree file diff after a Git change, matching the macOS
* contract: the view reloads in place with the same repository, path, and
- * HEAD-to-worktree semantics used to open it, shows an empty state when the
+ * index or HEAD-to-worktree semantics used to open it, shows an empty state when the
* file has no remaining diff, and closes only when Git status no longer lists
* the file. Failed reads keep the current content instead of closing.
*/
@@ -62,7 +63,10 @@ export async function refreshWorkingTreeFileDiff(
{
buffers,
loadStatus = getGitStatus,
- loadDiff = getWorkingTreePathDiff,
+ loadDiff = (root, path, untracked, originalPath, staged) =>
+ staged
+ ? getFileDiff(root, path, true)
+ : getWorkingTreePathDiff(root, path, untracked, originalPath),
}: WorkingTreeDiffRefreshDependencies,
): Promise {
const startingDiff = buffers.read(bufferId);
@@ -88,12 +92,14 @@ export async function refreshWorkingTreeFileDiff(
filePath: target.filePath,
...(originalPath ? { originalPath } : {}),
untracked: statusFile.status === "untracked",
+ ...(target.staged ? { staged: true } : {}),
};
const diff = await loadDiff(
nextTarget.repoPath,
nextTarget.filePath,
nextTarget.untracked,
nextTarget.originalPath,
+ ...(nextTarget.staged ? [true] : []),
);
if (!isCurrent() || !diff) return "skipped";
diff --git a/windows/tauri/src/features/git/stores/git-workspace-commit.store.ts b/windows/tauri/src/features/git/stores/git-workspace-commit.store.ts
new file mode 100644
index 000000000..cfb074046
--- /dev/null
+++ b/windows/tauri/src/features/git/stores/git-workspace-commit.store.ts
@@ -0,0 +1,24 @@
+import { createStore } from "zustand/vanilla";
+import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store";
+import {
+ cancelWorkspaceCommit,
+ prepareWorkspaceCommit,
+ stepWorkspaceCommit,
+} from "../api/git-workspace-commit-api";
+import { createWorkspaceCommitWorkflow } from "../services/git-workspace-commit-workflow";
+
+export const useWorkspaceCommitStore = createWorkspaceScopedStore("git-workspace-commit", () =>
+ createStore<{
+ workflow: ReturnType;
+ draftOwner: string | null;
+ setDraftOwner: (owner: string | null) => void;
+ }>((set) => ({
+ draftOwner: null,
+ setDraftOwner: (draftOwner) => set({ draftOwner }),
+ workflow: createWorkspaceCommitWorkflow({
+ prepare: prepareWorkspaceCommit,
+ step: stepWorkspaceCommit,
+ cancel: cancelWorkspaceCommit,
+ }),
+ })),
+);
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 d515f4bae..0912597ae 100644
--- a/windows/tauri/src/features/git/types/git-diff.types.ts
+++ b/windows/tauri/src/features/git/types/git-diff.types.ts
@@ -103,6 +103,8 @@ export interface ImageDiffViewerProps {
* initial open instead of re-deriving them from workspace display paths.
*/
export interface WorkingTreeDiffTarget {
+ /** Preserve index review semantics across automatic refreshes. */
+ staged?: boolean;
repoPath: string;
/** Path relative to repoPath. */
filePath: string;
diff --git a/windows/tauri/src/features/git/types/git-workspace-commit.types.ts b/windows/tauri/src/features/git/types/git-workspace-commit.types.ts
new file mode 100644
index 000000000..044e138c9
--- /dev/null
+++ b/windows/tauri/src/features/git/types/git-workspace-commit.types.ts
@@ -0,0 +1,57 @@
+/** Wire DTOs owned by Rust git::workspace_commit; clients return continuations unchanged. */
+export interface WorkspaceRepositoryBinding {
+ id: string;
+ root: string;
+}
+export interface WorkspaceCommitState {
+ head: string | null;
+ branch: string | null;
+ indexEntries: string;
+ gitlinks: { path: string; revision: string }[];
+ stagedPaths: string[];
+ conflictedPaths: string[];
+}
+export interface WorkspaceCommitRelation {
+ parent: string;
+ child: string;
+ path: string;
+}
+export interface WorkspaceCommitPlan {
+ repositories: WorkspaceRepositoryBinding[];
+ message: string;
+ amend: boolean;
+ push: boolean;
+ includeParentReferences: boolean;
+ isRetry: boolean;
+ orderedIds: string[];
+ propagatedRelations: WorkspaceCommitRelation[];
+ dependencyRelations: WorkspaceCommitRelation[];
+ states: Record;
+ committedIds: string[];
+ pendingPushIds: string[];
+}
+export interface WorkspaceCommitSession {
+ plan: WorkspaceCommitPlan;
+ states: Record;
+ results: Record;
+ blocked: string[];
+ cursor: number;
+ commandFailed: boolean;
+ finished: boolean;
+ succeeded: boolean;
+ canRetry: boolean;
+}
+export interface WorkspaceCommitRequest {
+ repositories: WorkspaceRepositoryBinding[];
+ message: string;
+ amend: boolean;
+ push: boolean;
+ includeParentReferences: boolean;
+ previous?: WorkspaceCommitSession;
+ reviewed?: WorkspaceCommitPlan;
+}
+export interface WorkspaceCommitPreparation {
+ session: WorkspaceCommitSession;
+ reviewChanged: boolean;
+ requiresConfirmation: boolean;
+}
diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts
index 2b04c4a37..d173d81cc 100644
--- a/windows/tauri/src/features/git/types/git.types.ts
+++ b/windows/tauri/src/features/git/types/git.types.ts
@@ -9,6 +9,8 @@ export interface GitFile {
repositoryOriginalRelativePath?: string;
status: "modified" | "added" | "deleted" | "untracked" | "renamed";
staged: boolean;
+ canToggleStaging?: boolean;
+ submodule?: { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean };
/** Raw porcelain XY status retained for whole-path commit review semantics. */
rawStatus?: string;
/** Whether the worktree differs from the index for this path. */
diff --git a/windows/tauri/src/features/git/utils/git-status-model.test.ts b/windows/tauri/src/features/git/utils/git-status-model.test.ts
index 670cff497..b29193e7c 100644
--- a/windows/tauri/src/features/git/utils/git-status-model.test.ts
+++ b/windows/tauri/src/features/git/utils/git-status-model.test.ts
@@ -1,9 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { GitFile } from "../types/git.types";
-import {
- buildGitStatusPresentation,
- buildVisibleGitFiles,
-} from "./git-status-model";
+import { buildGitStatusPresentation, buildVisibleGitFiles } from "./git-status-model";
const files: GitFile[] = [
{ path: "src/tracked.ts", status: "modified", staged: false },
@@ -22,13 +19,10 @@ describe("Git status model", () => {
test("includes untracked files when the setting is enabled", () => {
const presentation = buildVisibleGitFiles(files, true);
expect(presentation.files).toEqual(files);
- expect([...presentation.fileByPath.keys()]).toEqual([
- "src/tracked.ts",
- "src/new.ts",
- ]);
+ expect([...presentation.fileByPath.keys()]).toEqual(["src/tracked.ts", "src/new.ts"]);
});
- test("coalesces an index deletion and same-path recreation for whole-path review", () => {
+ test("preserves the staged deletion when a worktree file is recreated", () => {
const splitStatus: GitFile[] = [
{
path: "src/recreated.ts",
@@ -50,12 +44,9 @@ describe("Git status model", () => {
expect(visible.files).toEqual([
{
...splitStatus[0],
- status: "modified",
worktree: true,
},
]);
- expect(buildGitStatusPresentation(splitStatus).visibleFiles).toEqual(
- visible.files,
- );
+ expect(buildGitStatusPresentation(splitStatus).visibleFiles).toEqual(visible.files);
});
});
diff --git a/windows/tauri/src/features/git/utils/git-status-model.ts b/windows/tauri/src/features/git/utils/git-status-model.ts
index 3d0833b8e..20ea1b0ec 100644
--- a/windows/tauri/src/features/git/utils/git-status-model.ts
+++ b/windows/tauri/src/features/git/utils/git-status-model.ts
@@ -1,11 +1,7 @@
-import {
- buildPathTree,
- type PathTreeNode,
-} from "@/features/sidebar/lib/path-tree";
+import { buildPathTree, type PathTreeNode } from "@/features/sidebar/lib/path-tree";
import type { GitFile } from "../types/git.types";
-export type GitStatusGroup =
- "added" | "modified" | "deleted" | "renamed" | "untracked";
+export type GitStatusGroup = "added" | "modified" | "deleted" | "renamed" | "untracked";
export const GIT_STATUS_ORDER: GitStatusGroup[] = [
"added",
@@ -52,19 +48,13 @@ const createEmptyGitStatusGroups = (): Record => ({
});
function mergeWholePathStatus(existing: GitFile, incoming: GitFile): GitFile {
- const deleted = [existing, incoming].find(
- (file) => file.status === "deleted",
- );
- const recreated = [existing, incoming].find(
- (file) => file.status === "untracked",
- );
+ const deleted = [existing, incoming].find((file) => file.status === "deleted");
+ const recreated = [existing, incoming].find((file) => file.status === "untracked");
if (deleted && recreated) {
- // Git reports an index deletion and a same-path untracked file separately.
- // Selected-path commit stages the current file, so present it as the
- // modified whole-path snapshot that the user will actually commit.
+ // The checkbox reflects the index deletion. Keep the recreated worktree
+ // content available for diff/staging without relabeling the staged change.
return {
...deleted,
- status: "modified",
staged: deleted.staged || recreated.staged,
worktree: true,
};
@@ -76,19 +66,16 @@ function coalesceWholePathStatuses(files: readonly GitFile[]): GitFile[] {
const fileByPath = new Map();
for (const file of files) {
const existing = fileByPath.get(file.path);
- fileByPath.set(
- file.path,
- existing ? mergeWholePathStatus(existing, file) : file,
- );
+ fileByPath.set(file.path, existing ? mergeWholePathStatus(existing, file) : file);
}
return [...fileByPath.values()];
}
-export function buildGitFolderTree(fileList: GitFile[]): GitFolderTree {
+export function buildGitFolderTree(fileList: GitFile[], repositoryRelative = false): GitFolderTree {
const nodes = buildPathTree(fileList, {
- getKey: (file) =>
- `${file.path}:${file.staged ? "staged" : "unstaged"}:${file.status}`,
- getPath: (file) => file.path,
+ getKey: (file) => `${file.path}:${file.staged ? "staged" : "unstaged"}:${file.status}`,
+ getPath: (file) =>
+ repositoryRelative ? (file.repositoryRelativePath ?? file.path) : file.path,
});
const folderStateById = new Map();
@@ -99,8 +86,7 @@ export function buildGitFolderTree(fileList: GitFile[]): GitFolderTree {
folderStateById.set(node.id, {
descendantFilePaths: descendantFiles.map((file) => file.path),
areAllDescendantFilesStaged:
- descendantFiles.length > 0 &&
- descendantFiles.every((file) => file.staged),
+ descendantFiles.length > 0 && descendantFiles.every((file) => file.staged),
});
return descendantFiles;
};
@@ -109,9 +95,7 @@ export function buildGitFolderTree(fileList: GitFile[]): GitFolderTree {
return { nodes, folderStateById };
}
-export function buildGitStatusPresentation(
- files: GitFile[],
-): GitStatusPresentation {
+export function buildGitStatusPresentation(files: GitFile[]): GitStatusPresentation {
const stagedFiles: GitFile[] = [];
const unstagedFiles: GitFile[] = [];
const displayFileByPath = new Map();
diff --git a/windows/tauri/src/features/git/utils/git-workspace-commit-bindings.test.ts b/windows/tauri/src/features/git/utils/git-workspace-commit-bindings.test.ts
new file mode 100644
index 000000000..56b8c2c12
--- /dev/null
+++ b/windows/tauri/src/features/git/utils/git-workspace-commit-bindings.test.ts
@@ -0,0 +1,20 @@
+import { expect, test } from "bun:test";
+import { workspaceCommitBindings } from "./git-workspace-commit-bindings";
+
+test("binds nested, enclosing and same-volume sibling repositories without absolute IDs", () => {
+ expect(
+ workspaceCommitBindings("C:\\work\\project", [
+ "c:/work/project",
+ "C:/work/project/libs/B",
+ "C:/work",
+ "C:/other",
+ ]).map((binding) => binding.id),
+ ).toEqual([".", "libs/B", "..", "../../other"]);
+});
+test("normalizes verbatim roots and gives another volume a stable virtual ID", () => {
+ expect(
+ workspaceCommitBindings("C:/work", ["\\\\?\\C:\\work\\B", "D:/repo"]).map(
+ (binding) => binding.id,
+ ),
+ ).toEqual(["B", "external/D%3A/repo"]);
+});
diff --git a/windows/tauri/src/features/git/utils/git-workspace-commit-bindings.ts b/windows/tauri/src/features/git/utils/git-workspace-commit-bindings.ts
new file mode 100644
index 000000000..807c4edaa
--- /dev/null
+++ b/windows/tauri/src/features/git/utils/git-workspace-commit-bindings.ts
@@ -0,0 +1,26 @@
+import { normalizePath } from "@/utils/path-helpers";
+import type { WorkspaceRepositoryBinding } from "../types/git-workspace-commit.types";
+
+/** Bind native roots to portable workspace IDs, including enclosing repositories. */
+export function workspaceCommitBindings(
+ workspace: string,
+ roots: readonly string[],
+): WorkspaceRepositoryBinding[] {
+ const base = normalizePath(workspace).replace(/\/+$/, "").split("/");
+ return [...new Set(roots)].map((root) => {
+ const parts = normalizePath(root).replace(/\/+$/, "").split("/");
+ let common = 0;
+ while (
+ common < base.length &&
+ common < parts.length &&
+ base[common]!.toLowerCase() === parts[common]!.toLowerCase()
+ )
+ common++;
+ // Manually selected repositories on another volume have a virtual workspace ID.
+ const id =
+ common === 0
+ ? `external/${parts.map((part) => encodeURIComponent(part)).join("/")}`
+ : [...base.slice(common).map(() => ".."), ...parts.slice(common)].join("/") || ".";
+ return { id, root };
+ });
+}
diff --git a/windows/tauri/src/i18n/git-workspace-commit.ts b/windows/tauri/src/i18n/git-workspace-commit.ts
new file mode 100644
index 000000000..d71ea999c
--- /dev/null
+++ b/windows/tauri/src/i18n/git-workspace-commit.ts
@@ -0,0 +1,46 @@
+export const workspaceCommitEnglish = {
+ "git.workspaceCommit.review": "Review workspace commit",
+ "git.workspaceCommit.changed": "The plan changed. Review it again before continuing.",
+ "git.workspaceCommit.parents": "Update parent repository references",
+ "git.workspaceCommit.order":
+ "Repositories run in the order shown. Completed commits are kept if a later step fails.",
+ "git.workspaceCommit.pushOnly": "Push existing commit only",
+ "git.workspaceCommit.reference": "Update reference: {path} → {child}",
+ "git.workspaceCommit.retry": "Review and retry unfinished work",
+ "git.workspaceCommit.dismiss": "Dismiss results",
+ "git.workspaceCommit.stop": "Stop",
+ "git.workspaceCommit.attention": "Repository needs attention",
+ "git.workspaceCommit.pending": "Pending",
+ "git.workspaceCommit.notIncluded": "Not included in the updated plan",
+ "git.workspaceCommit.waitingForSubmodule": "Waiting for submodule",
+ "git.workspaceCommit.committed": "Committed",
+ "git.workspaceCommit.committedPushPending": "Committed; push pending",
+ "git.workspaceCommit.committedAndPushed": "Committed and pushed",
+ "git.workspaceCommit.pushFailed": "Push failed",
+ "git.workspaceCommit.headAdvanced": "HEAD advanced; review before continuing",
+ "git.workspaceCommit.outcomeUnknown": "Commit outcome unknown; inspect before retrying",
+ "git.workspaceCommit.dirtySubmodule":
+ "Uncommitted changes in submodule; commit its files in that repository",
+} as const;
+export const workspaceCommitChinese: Record = {
+ "git.workspaceCommit.review": "确认工作区提交",
+ "git.workspaceCommit.changed": "提交计划已变化,请重新确认后继续。",
+ "git.workspaceCommit.parents": "更新父仓库引用",
+ "git.workspaceCommit.order": "按下列顺序执行。后续步骤失败时,已完成的提交会保留。",
+ "git.workspaceCommit.pushOnly": "仅推送已有提交",
+ "git.workspaceCommit.reference": "更新引用:{path} → {child}",
+ "git.workspaceCommit.retry": "确认并重试未完成的操作",
+ "git.workspaceCommit.dismiss": "清除结果",
+ "git.workspaceCommit.stop": "停止",
+ "git.workspaceCommit.attention": "仓库需要处理",
+ "git.workspaceCommit.pending": "等待执行",
+ "git.workspaceCommit.notIncluded": "未包含在更新后的计划中",
+ "git.workspaceCommit.waitingForSubmodule": "等待子模块",
+ "git.workspaceCommit.committed": "已提交",
+ "git.workspaceCommit.committedPushPending": "已提交,等待推送",
+ "git.workspaceCommit.committedAndPushed": "已提交并推送",
+ "git.workspaceCommit.pushFailed": "推送失败",
+ "git.workspaceCommit.headAdvanced": "HEAD 已变化,请确认后继续",
+ "git.workspaceCommit.outcomeUnknown": "提交结果未知,请检查后再重试",
+ "git.workspaceCommit.dirtySubmodule": "子模块有未提交的改动,请在对应仓库提交文件",
+};
diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts
index 111652f0b..d620d07f6 100644
--- a/windows/tauri/src/i18n/locale.ts
+++ b/windows/tauri/src/i18n/locale.ts
@@ -1,3 +1,4 @@
+import { workspaceCommitChinese, workspaceCommitEnglish } from "./git-workspace-commit";
import { aiCommitChinese, aiCommitEnglish } from "./ai-commit";
export const DISPLAY_LANGUAGES = ["en-US", "zh-CN"] as const;
@@ -6,6 +7,7 @@ export type DisplayLanguage = (typeof DISPLAY_LANGUAGES)[number];
const catalogs = {
"en-US": {
...aiCommitEnglish,
+ ...workspaceCommitEnglish,
"git.console.details": "Command details",
"git.console.historyTruncated": "Earlier Git commands were truncated to limit memory use.",
"git.console.options": "Git command options",
@@ -4507,6 +4509,7 @@ const catalogs = {
},
"zh-CN": {
...aiCommitChinese,
+ ...workspaceCommitChinese,
"git.console.details": "命令详情",
"git.console.historyTruncated": "为限制内存占用,较早的 Git 执行记录已截断,无法展开。",
"git.console.options": "Git 命令选项",
diff --git a/windows/tauri/src/platform/core-result-adapter.test.ts b/windows/tauri/src/platform/core-result-adapter.test.ts
index bff67c32e..2dd3f603d 100644
--- a/windows/tauri/src/platform/core-result-adapter.test.ts
+++ b/windows/tauri/src/platform/core-result-adapter.test.ts
@@ -59,6 +59,39 @@ describe("git status result adaptation", () => {
});
});
+test("workspace status keeps index-only additions and Core submodule checkbox eligibility", () => {
+ const submodule = { commitChanged: false, trackedChanges: true, untrackedChanges: false };
+ const result = adaptCoreResult<{ files: Array> }>(
+ "git_status",
+ { includeIndexOnlyChanges: true },
+ {
+ changes: [
+ {
+ path: "staged-only.ts",
+ status: "AD",
+ staged: true,
+ worktree: true,
+ canToggleStaging: true,
+ },
+ {
+ path: "libs/B",
+ status: " M",
+ staged: false,
+ worktree: true,
+ canToggleStaging: false,
+ submodule,
+ },
+ ],
+ },
+ );
+ expect(result.files[0]).toMatchObject({
+ path: "staged-only.ts",
+ staged: true,
+ canToggleStaging: true,
+ });
+ expect(result.files[1]).toMatchObject({ path: "libs/B", canToggleStaging: false, submodule });
+});
+
describe("git checkout result adaptation", () => {
test("maps a successful core checkout to the UI checkout result", () => {
const result = adaptCoreResult(
@@ -97,13 +130,9 @@ describe("git checkout result adaptation", () => {
describe("git repository discovery result adaptation", () => {
test("accepts the read-only repository root string", () => {
- expect(
- adaptCoreResult(
- "git_discover_repo",
- { path: "C:/work/src" },
- "C:/work\n",
- ),
- ).toBe("C:/work");
+ expect(adaptCoreResult("git_discover_repo", { path: "C:/work/src" }, "C:/work\n")).toBe(
+ "C:/work",
+ );
});
test("keeps compatibility with the legacy command output envelope", () => {
diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts
index 7405defc9..274161477 100644
--- a/windows/tauri/src/platform/core-result-adapter.ts
+++ b/windows/tauri/src/platform/core-result-adapter.ts
@@ -147,16 +147,23 @@ export function adaptCoreResult(
behind: data.behind ?? 0,
files: Array.isArray(data.changes)
? data.changes
- .filter((change: JsonRecord) => String(change.status ?? "") !== "AD")
+ .filter(
+ (change: JsonRecord) =>
+ args?.includeIndexOnlyChanges || String(change.status ?? "") !== "AD",
+ )
.map((change: JsonRecord) => ({
- path: change.path,
- ...(typeof change.originalPath === "string"
- ? { originalPath: change.originalPath }
- : {}),
- status: normalizeStatus(String(change.status ?? ""), Boolean(change.untracked)),
- staged: Boolean(change.staged),
- rawStatus: String(change.status ?? ""),
- worktree: Boolean(change.worktree),
+ path: change.path,
+ ...(typeof change.originalPath === "string"
+ ? { originalPath: change.originalPath }
+ : {}),
+ status: normalizeStatus(String(change.status ?? ""), Boolean(change.untracked)),
+ staged: Boolean(change.staged),
+ rawStatus: String(change.status ?? ""),
+ ...(typeof change.canToggleStaging === "boolean"
+ ? { canToggleStaging: change.canToggleStaging }
+ : {}),
+ ...(change.submodule ? { submodule: change.submodule } : {}),
+ worktree: Boolean(change.worktree),
}))
: [],
} as T;
@@ -287,7 +294,7 @@ export function adaptCoreResult(
case "git_status_diff_stats":
return adaptDiffStats(args, value) as T;
case "git_discover_repo":
- return String(typeof value === "string" ? value : data.output ?? "").trim() as T;
+ return String(typeof value === "string" ? value : (data.output ?? "")).trim() as T;
case "git_get_remotes": {
const remotes = new Map();
for (const line of String(data.output ?? "").split("\n")) {
diff --git a/windows/tauri/src/workbench-app.tsx b/windows/tauri/src/workbench-app.tsx
index f8523e30c..d011abe5a 100644
--- a/windows/tauri/src/workbench-app.tsx
+++ b/windows/tauri/src/workbench-app.tsx
@@ -18,6 +18,7 @@ import { GitPullDialogHost } from "@/features/git/components/git-pull-dialog";
import { GitRebaseDialogHost } from "@/features/git/components/git-rebase-dialog";
import { GitWorktreeDialogHost } from "@/features/git/components/git-worktree-dialog";
import { GitPatchDialogHost } from "@/features/git/components/git-patch-dialog";
+import { GitWorkspaceCommitHost } from "@/features/git/runtime/git-workspace-commit-host";
import { GitMetadataWatchHost } from "@/features/git/runtime/git-metadata-watch-host";
import { MainLayout } from "./features/layout/components/main-layout";
@@ -92,6 +93,7 @@ function WorkbenchApp() {
+
From f4eab185a6c38d1187e62210b751cef4371e977f Mon Sep 17 00:00:00 2001
From: Lichenkang <2188718831@qq.com>
Date: Mon, 28 Sep 2026 15:48:28 +0800
Subject: [PATCH 2/3] =?UTF-8?q?feat(agent):=20=E6=94=AF=E6=8C=81=20Codex?=
=?UTF-8?q?=20=E6=9C=AC=E6=9C=BA=E8=AE=A2=E9=98=85=E4=B8=8E=E5=AF=B9?=
=?UTF-8?q?=E8=AF=9D=E6=A1=86=E5=86=85=E9=A2=9D=E5=BA=A6=E6=98=BE=E7=A4=BA?=
=?UTF-8?q?=20(#950)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(agent): add Codex subscription provider and compact quota indicator
* fix(agent): keep subscription routing official and bound login cancellation
* test(agent): keep probe descendant stdin open during cancellation
---
...026-09-25-shared-acp-agent-conversation.md | 20 +-
docs/development/platform-parity-matrix.csv | 2 +
docs/development/platform-parity-matrix.md | 10 +-
macos/Resources/en.lproj/Localizable.strings | 30 ++
.../zh-Hans.lproj/Localizable.strings | 30 ++
.../AppModel/AppModel+AgentConversation.swift | 11 +-
.../AppModel/AppModel+AgentProviders.swift | 12 +-
.../Lithe/Models/Settings/AppSettings.swift | 19 +
.../MacOS/Agent/MacACPAgentTransport.swift | 7 +-
.../Lithe/Views/Agent/AgentComposerView.swift | 7 +
.../Views/Agent/AgentConversationView.swift | 26 ++
.../Views/Agent/AgentPanelSettingsView.swift | 4 +-
.../AgentProviderConfigurationView.swift | 25 +-
.../Agent/AgentSubscriptionQuotaView.swift | 78 ++++
.../Application/AgentConnectionModel.swift | 61 +++
.../Application/AgentSubscriptionQuota.swift | 35 ++
.../Agent/AgentConversationPorts.swift | 14 +-
.../AgentConversationFeatureModelTests.swift | 69 +++
.../AgentConversationPresentationTests.swift | 17 +
.../AgentProviderConfigurationTests.swift | 18 +
rust/lithe-agent-host/src/lib.rs | 106 ++++-
rust/lithe-agent-host/src/subscription.rs | 400 ++++++++++++++++++
.../src/subscription/tests.rs | 166 ++++++++
rust/lithe-agent-host/src/tests.rs | 163 ++++++-
rust/lithe-agent-host/tests/real_agent.rs | 5 +-
shared/contracts/application-boundary.md | 2 +-
shared/contracts/rust-core-api.md | 52 ++-
shared/fixtures/agent/acp-events-v1.json | 7 +
shared/platform-feature-matrix.json | 53 +++
29 files changed, 1409 insertions(+), 40 deletions(-)
create mode 100644 macos/Sources/Lithe/Views/Agent/AgentSubscriptionQuotaView.swift
create mode 100644 macos/Sources/LitheAgentConversationModule/Application/AgentSubscriptionQuota.swift
create mode 100644 rust/lithe-agent-host/src/subscription.rs
create mode 100644 rust/lithe-agent-host/src/subscription/tests.rs
diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md
index e8a815fd7..8942b1c3f 100644
--- a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md
+++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md
@@ -4,7 +4,7 @@
## 先说结论
-Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 Agent。每个项目只有一个 Agent 进程,一个进程里可以有多个会话;会话历史由 Agent 自己保存;Lithe 负责显示,并单独保存收藏、自定义标题和可恢复的隐藏状态。第一阶段只支持用户自己的 API Key,不接任何官方账号登录。Agent 需要的 Node.js 由用户自己安装,Lithe 只负责检测;ACP 适配器由 Lithe 提供一键安装,安装时用的是用户本机的 npm。ACP(Agent Client Protocol,编辑器与 Agent 之间的对话协议)连接和进程管理写在同一个 Rust crate 里,Mac 与未来的 Windows 只各自实现界面。
+Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 Agent。每个项目的每种 Agent 只有一个对话连接,一个连接里可以有多个会话;会话历史由 Agent 自己保存;Lithe 负责显示,并单独保存收藏、自定义标题和可恢复的隐藏状态。供应商管理允许 Codex 在自定义 API Key 和本机 ChatGPT 订阅间切换;Claude 仍只支持 API Key。订阅额度放在输入框上下文的右侧,不新增页面。Agent 需要的 Node.js 由用户自己安装,Lithe 只负责检测;ACP 适配器由 Lithe 提供一键安装,安装时用的是用户本机的 npm。ACP(Agent Client Protocol,编辑器与 Agent 之间的对话协议)连接和进程管理写在同一个 Rust crate 里,Mac 与未来的 Windows 只各自实现界面。
## 问题
@@ -19,13 +19,16 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机
- **共享实现**:`rust/lithe-agent-host` 使用官方 `agent-client-protocol` SDK。一个 `AgentHandle` 对应一个项目的 Agent 进程和 ACP 连接,负责初始化、网关登录、会话新建/列出/加载、消息、权限、取消和进程树清理。Mac 通过 Rust Core C ABI(`lithe_agent_open_json`、`lithe_agent_send_json`、`lithe_agent_close`)调用;Windows 以后直接依赖同一个 crate。命令和事件的 JSON 形状由 `shared/fixtures/agent/acp-events-v1.json` 固定。
- **按需启动,跟着项目走**:`LitheAgentConversationModule` 是内置可选模块,默认禁用。每个项目有自己的模块运行时,所以会话天然属于项目。切换标签或窗口不会结束任何会话,后台项目的这一轮会继续跑完;只有关闭项目、关闭功能或退出应用时才停止 Agent。后台项目的会话在等待权限时,项目标签上会显示提醒点。
-- **只用 API Key 登录**:初始化时声明 `auth._meta.gateway = true`,然后只用 `gateway` 方式登录,把服务商地址和 `Authorization: Bearer