();
+ visibleGroups.forEach((group) => group.entries.forEach((entry) => seen.add(entry.path)));
+ return Array.from(seen);
+ }, [visibleGroups]);
+ const revertAllCount = allChangePaths.length;
+
+ const handleConfirmRevertAll = React.useCallback(async () => {
+ if (!onRevertAll || isRevertingAll || allChangePaths.length === 0) {
+ return;
+ }
+ await onRevertAll(allChangePaths);
+ setRevertAllOpen(false);
+ }, [allChangePaths, isRevertingAll, onRevertAll]);
+
+ const renderHeader = React.useCallback(
+ (group: ChangesGroupConfig, isFirst: boolean) => {
+ const collapsed = collapsedGroups.has(group.id);
+ const count = group.entries.length;
+ return (
+
+
+
+
+
+ );
+ },
+ [collapsedGroups, toggleGroupCollapsed]
+ );
+
+ const renderDirectory = React.useCallback(
+ (group: ChangesGroupConfig, directory: ChangesTreeDirectoryNode, depth: number) => {
+ const isExpanded = expandedDirectories.has(expandedKey(group.id, directory.path));
+ return (
+
+
+
+
+ );
+ },
+ [expandedDirectories, t, toggleDirectoryExpanded]
+ );
+
+ const renderRow = React.useCallback(
+ (row: PanelRow, isFirstRow: boolean) => {
+ if (row.type === 'revert-all') {
+ return (
+
+
+
+ );
+ }
+
+ const group = visibleGroups[row.groupIndex];
+ if (!group) return null;
+
+ if (row.type === 'header') {
+ return renderHeader(group, isFirstRow);
+ }
+
+ if (row.type === 'directory') {
+ return renderDirectory(group, row.directory, row.depth);
+ }
+
+ const file = row.file;
+ return (
+ group.onActionFile(file.path)}
+ stats={diffStats?.[file.path]}
+ onViewDiff={() => group.onViewDiff(file.path)}
+ onRevert={() => group.onRevertFile(file.path)}
+ isReverting={revertingPaths.has(file.path) || isRevertingAll}
+ rowPaddingClassName={ROW_PADDING_CLASSNAME}
+ indentPx={row.depth * TREE_INDENT_PX}
+ actionAtStart={!isTreeView}
+ showRevert={group.showRevertActions !== false}
+ />
+ );
+ },
+ [diffStats, isRevertingAll, isTreeView, renderDirectory, renderHeader, revertingPaths, t, visibleGroups]
+ );
+
+ // A divider is drawn above a file/directory row only when the row directly above
+ // it belongs to the same group (so headers never get a spurious top border).
+ const showDivider = React.useCallback(
+ (index: number): boolean => {
+ const row = rows[index];
+ const previous = rows[index - 1];
+ if (!row || !previous) return false;
+ if (row.type !== 'file' && row.type !== 'directory') return false;
+ if (previous.type !== 'file' && previous.type !== 'directory') return false;
+ return previous.groupIndex === row.groupIndex;
+ },
+ [rows]
+ );
+
+ return (
+ <>
+
+
+ {shouldVirtualize ? (
+
+ {virtualRows.map((item) => {
+ const row = rows[item.index];
+ if (!row) return null;
+ return (
+
+ {renderRow(row, item.index === 0)}
+
+ );
+ })}
+
+ ) : (
+
+ {rows.map((row, index) => (
+
+ {renderRow(row, index === 0)}
+
+ ))}
+
+ )}
+
+
+
+
+
+ >
+ );
+};
diff --git a/packages/ui/src/components/views/git/ChangesSection.tsx b/packages/ui/src/components/views/git/ChangesSection.tsx
deleted file mode 100644
index 13aa0176..00000000
--- a/packages/ui/src/components/views/git/ChangesSection.tsx
+++ /dev/null
@@ -1,573 +0,0 @@
-import React from 'react';
-import { useVirtualizer } from '@tanstack/react-virtual';
-import { Checkbox } from '@/components/ui/checkbox';
-import { Button } from '@/components/ui/button';
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog';
-import { ScrollShadow } from '@/components/ui/ScrollShadow';
-import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
-import { Icon } from "@/components/icon/Icon";
-import { ChangeRow } from './ChangeRow';
-import type { GitStatus } from '@/lib/api/types';
-import { cn } from '@/lib/utils';
-import { useUIStore } from '@/stores/useUIStore';
-import { useI18n } from '@/lib/i18n';
-
-interface ChangesSectionProps {
- changeEntries: GitStatus['files'];
- selectedPaths: Set;
- diffStats: Record | undefined;
- revertingPaths: Set;
- onToggleFile: (path: string) => void;
- onSelectAll: () => void;
- onClearSelection: () => void;
- onRevertAll?: (paths: string[]) => Promise | void;
- onViewDiff: (path: string) => void;
- onRevertFile: (path: string) => void;
- isRevertingAll?: boolean;
- maxListHeightClassName?: string;
- onVisiblePathsChange?: (paths: string[]) => void;
- onOpenStashes?: () => void;
-}
-
-const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 1000;
-const CHANGE_ROW_ESTIMATE_PX = 34;
-
-type ChangesTreeDirectoryNode = {
- id: string;
- path: string;
- name: string;
- children: Map;
- directFiles: GitStatus['files'];
- files: GitStatus['files'];
-};
-
-type FlattenedTreeRow =
- | {
- key: string;
- kind: 'directory';
- depth: number;
- directory: ChangesTreeDirectoryNode;
- }
- | {
- key: string;
- kind: 'file';
- depth: number;
- file: GitStatus['files'][number];
- };
-
-const TREE_INDENT_PX = 14;
-
-const normalizePathForTree = (value: string): string => value.replace(/\\/g, '/').replace(/^\/+/, '').trim();
-
-const createDirectoryNode = (path: string, name: string): ChangesTreeDirectoryNode => ({
- id: `dir:${path}`,
- path,
- name,
- children: new Map(),
- directFiles: [],
- files: [],
-});
-
-const buildChangesTree = (entries: GitStatus['files']): ChangesTreeDirectoryNode => {
- const root = createDirectoryNode('', '');
-
- for (const file of entries) {
- const normalized = normalizePathForTree(file.path);
- if (!normalized) {
- continue;
- }
-
- const segments = normalized.split('/').filter(Boolean);
- const directorySegments = segments.slice(0, -1);
- let current = root;
- current.files.push(file);
-
- if (directorySegments.length > 0) {
- let currentPath = '';
- for (const segment of directorySegments) {
- currentPath = currentPath ? `${currentPath}/${segment}` : segment;
- const existing = current.children.get(segment);
- if (existing) {
- existing.files.push(file);
- current = existing;
- continue;
- }
-
- const created = createDirectoryNode(currentPath, segment);
- created.files.push(file);
- current.children.set(segment, created);
- current = created;
- }
- }
-
- current.directFiles.push(file);
- }
-
- return root;
-};
-
-const flattenChangesTree = (
- root: ChangesTreeDirectoryNode,
- expandedDirectories: Set,
-): FlattenedTreeRow[] => {
- const rows: FlattenedTreeRow[] = [];
-
- const walk = (node: ChangesTreeDirectoryNode, depth: number) => {
- const directories = Array.from(node.children.values()).sort((a, b) => a.path.localeCompare(b.path));
- for (const directory of directories) {
- rows.push({
- key: directory.id,
- kind: 'directory',
- depth,
- directory,
- });
-
- if (expandedDirectories.has(directory.path)) {
- walk(directory, depth + 1);
- }
- }
-
- const directFiles = [...node.directFiles].sort((a, b) => a.path.localeCompare(b.path));
-
- for (const file of directFiles) {
- rows.push({
- key: `file:${normalizePathForTree(file.path)}`,
- kind: 'file',
- depth,
- file,
- });
- }
- };
-
- walk(root, 0);
- return rows;
-};
-
-const getDirectorySelectionState = (
- directory: ChangesTreeDirectoryNode,
- selectedPaths: Set
-): 'none' | 'partial' | 'all' => {
- if (directory.files.length === 0) {
- return 'none';
- }
-
- let selectedCount = 0;
- for (const file of directory.files) {
- if (selectedPaths.has(file.path)) {
- selectedCount += 1;
- }
- }
-
- if (selectedCount === 0) return 'none';
- if (selectedCount === directory.files.length) return 'all';
- return 'partial';
-};
-
-export const ChangesSection: React.FC = ({
- changeEntries,
- selectedPaths,
- diffStats,
- revertingPaths,
- onToggleFile,
- onSelectAll,
- onClearSelection,
- onRevertAll,
- onViewDiff,
- onRevertFile,
- isRevertingAll = false,
- maxListHeightClassName,
- onVisiblePathsChange,
- onOpenStashes,
-}) => {
- const { t } = useI18n();
- const scrollRef = React.useRef(null);
- const gitChangesViewMode = useUIStore((state) => state.gitChangesViewMode);
- const isTreeView = gitChangesViewMode === 'tree';
- const selectedCount = selectedPaths.size;
- const totalCount = changeEntries.length;
- const [confirmRevertAllOpen, setConfirmRevertAllOpen] = React.useState(false);
- const treeRoot = React.useMemo(() => buildChangesTree(changeEntries), [changeEntries]);
- const [expandedDirectories, setExpandedDirectories] = React.useState>(new Set());
-
- const topLevelDirectoryPaths = React.useMemo(
- () => Array.from(treeRoot.children.values()).map((directory) => directory.path),
- [treeRoot]
- );
-
- React.useEffect(() => {
- if (!isTreeView) {
- return;
- }
-
- setExpandedDirectories((previous) => {
- const next = new Set();
- const validTopLevel = new Set(topLevelDirectoryPaths);
-
- previous.forEach((path) => {
- if (path.includes('/')) {
- next.add(path);
- return;
- }
- if (validTopLevel.has(path)) {
- next.add(path);
- }
- });
-
- topLevelDirectoryPaths.forEach((path) => next.add(path));
- return next;
- });
- }, [isTreeView, topLevelDirectoryPaths]);
-
- const treeRows = React.useMemo(() => flattenChangesTree(treeRoot, expandedDirectories), [expandedDirectories, treeRoot]);
- const rowItems = React.useMemo(() => (isTreeView ? treeRows : changeEntries), [changeEntries, isTreeView, treeRows]);
- const rowCount = rowItems.length;
- const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
- const hasAnySelected = selectedCount > 0;
- const areAllSelected = totalCount > 0 && selectedCount === totalCount;
- const isPartiallySelected = hasAnySelected && !areAllSelected;
-
- const rowVirtualizer = useVirtualizer({
- count: rowCount,
- getScrollElement: () => scrollRef.current,
- estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
- overscan: 10,
- enabled: shouldVirtualize,
- });
-
- // Force virtualizer to remeasure when the scroll container transitions
- // from display:none (hidden tab via keep-alive) back to visible layout.
- // Without this, the virtualizer uses stale zero-height measurements and
- // renders no rows until the user scrolls.
- React.useEffect(() => {
- if (!shouldVirtualize) return;
- const el = scrollRef.current;
- if (!el) return;
-
- const observer = new ResizeObserver(() => {
- rowVirtualizer.measure();
- });
- observer.observe(el);
- return () => observer.disconnect();
- }, [shouldVirtualize, rowVirtualizer]);
-
- // Compute virtual rows with useMemo. We include totalSize as a dependency so
- // that when the ResizeObserver calls measure() — which clears the itemSizeCache
- // and recalculates — the size change invalidates the memo and getVirtualItems()
- // returns fresh rows. Using useMemo avoids calling getVirtualItems() directly in
- // the render body, which can trigger maybeNotify() → onChange() → useReducer
- // dispatch during render (React minified error #185).
- const totalSize = rowVirtualizer.getTotalSize();
- const virtualRows = React.useMemo(
- // totalSize invalidates the memo when the virtualizer recalculates after
- // measure/scroll, ensuring getVirtualItems() returns up-to-date rows.
- // Without it, the stable rowVirtualizer ref would never invalidate the memo
- // and rows would stay empty after measure().
- () => (shouldVirtualize && totalSize >= 0 ? rowVirtualizer.getVirtualItems() : []),
- [shouldVirtualize, rowVirtualizer, totalSize],
- );
-
- React.useEffect(() => {
- if (!onVisiblePathsChange) {
- return;
- }
-
- if (rowCount === 0) {
- onVisiblePathsChange([]);
- return;
- }
-
- const toVisiblePath = (item: GitStatus['files'][number] | FlattenedTreeRow): string | null => {
- if (!isTreeView) {
- return (item as GitStatus['files'][number]).path;
- }
-
- const treeItem = item as FlattenedTreeRow;
- return treeItem.kind === 'file' ? treeItem.file.path : null;
- };
-
- if (!shouldVirtualize) {
- onVisiblePathsChange(
- rowItems
- .slice(0, Math.min(30, rowCount))
- .map((item) => toVisiblePath(item))
- .filter((value): value is string => Boolean(value))
- );
- return;
- }
-
- onVisiblePathsChange(
- virtualRows
- .map((row) => rowItems[row.index])
- .map((item) => (item ? toVisiblePath(item) : null))
- .filter((value): value is string => Boolean(value))
- );
- }, [isTreeView, onVisiblePathsChange, rowCount, rowItems, shouldVirtualize, virtualRows]);
-
- const containerClassName = 'flex flex-col flex-1 min-h-0';
- const headerClassName = 'flex items-center justify-between gap-2 px-0 py-3 border-b border-border/40';
- const scrollOuterClassName = `flex-1 min-h-0 pr-0 ${maxListHeightClassName ?? ''}`.trim();
- const rowPaddingClassName = 'pl-0 pr-2';
-
- const toggleDirectoryExpanded = React.useCallback((path: string) => {
- setExpandedDirectories((previous) => {
- const next = new Set(previous);
- if (next.has(path)) {
- next.delete(path);
- } else {
- next.add(path);
- }
- return next;
- });
- }, []);
-
- const toggleDirectorySelection = React.useCallback((directory: ChangesTreeDirectoryNode) => {
- const state = getDirectorySelectionState(directory, selectedPaths);
- const shouldSelectAll = state !== 'all';
-
- for (const file of directory.files) {
- const isSelected = selectedPaths.has(file.path);
- if (shouldSelectAll && !isSelected) {
- onToggleFile(file.path);
- } else if (!shouldSelectAll && isSelected) {
- onToggleFile(file.path);
- }
- }
- }, [onToggleFile, selectedPaths]);
-
- const renderRow = React.useCallback((item: GitStatus['files'][number] | FlattenedTreeRow) => {
- if (!isTreeView) {
- const file = item as GitStatus['files'][number];
- return (
- onToggleFile(file.path)}
- onViewDiff={() => onViewDiff(file.path)}
- onRevert={() => onRevertFile(file.path)}
- isReverting={revertingPaths.has(file.path) || isRevertingAll}
- rowPaddingClassName={rowPaddingClassName}
- />
- );
- }
-
- const row = item as FlattenedTreeRow;
-
- if (row.kind === 'file') {
- const file = row.file;
- return (
- onToggleFile(file.path)}
- onViewDiff={() => onViewDiff(file.path)}
- onRevert={() => onRevertFile(file.path)}
- isReverting={revertingPaths.has(file.path) || isRevertingAll}
- rowPaddingClassName={rowPaddingClassName}
- indentPx={row.depth * TREE_INDENT_PX}
- />
- );
- }
-
- const directory = row.directory;
- const isExpanded = expandedDirectories.has(directory.path);
- const selectionState = getDirectorySelectionState(directory, selectedPaths);
-
- return (
-
-
- toggleDirectorySelection(directory)}
- ariaLabel={t('gitView.changes.toggleDirectorySelectionAria', { path: directory.path })}
- />
-
-
-
-
- );
- }, [
- diffStats,
- expandedDirectories,
- isRevertingAll,
- isTreeView,
- onRevertFile,
- onToggleFile,
- onViewDiff,
- revertingPaths,
- rowPaddingClassName,
- selectedPaths,
- t,
- toggleDirectoryExpanded,
- toggleDirectorySelection,
- ]);
-
- const handleConfirmRevertAll = React.useCallback(async () => {
- if (!onRevertAll || isRevertingAll || changeEntries.length === 0) {
- return;
- }
-
- await onRevertAll(changeEntries.map((entry) => entry.path));
- setConfirmRevertAllOpen(false);
- }, [changeEntries, isRevertingAll, onRevertAll]);
-
- return (
- <>
-
-
-
-
{t('gitView.changes.title')}
- {totalCount > 0 ? (
-
- (areAllSelected ? onClearSelection() : onSelectAll())}
- ariaLabel={areAllSelected ? t('gitView.changes.clearSelectionAria') : t('gitView.changes.selectAllAria')}
- />
- {selectedCount}/{totalCount}
-
- ) : null}
- {onOpenStashes ? (
-
- ) : null}
-
-
- {totalCount > 0 && onRevertAll ? (
-
- ) : null}
-
-
-
-
- {shouldVirtualize ? (
-
- {virtualRows.map((row) => {
- const item = rowItems[row.index];
- if (!item) {
- return null;
- }
-
- const key = isTreeView
- ? (item as FlattenedTreeRow).key
- : `file:${(item as GitStatus['files'][number]).path}`;
-
- return (
-
0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
- )}
- style={{ transform: `translateY(${row.start}px)` }}
- >
- {renderRow(item)}
-
- );
- })}
-
- ) : (
-
- {rowItems.map((item, index) => (
-
0 && 'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
- )}
- >
- {renderRow(item)}
-
- ))}
-
- )}
-
-
-
-
-
-
- >
- );
-};
diff --git a/packages/ui/src/components/views/git/CommitSection.tsx b/packages/ui/src/components/views/git/CommitSection.tsx
index a269cedd..7c7ac7b8 100644
--- a/packages/ui/src/components/views/git/CommitSection.tsx
+++ b/packages/ui/src/components/views/git/CommitSection.tsx
@@ -9,7 +9,7 @@ import { useI18n } from '@/lib/i18n';
type CommitAction = 'commit' | 'commitAndPush' | null;
interface CommitSectionProps {
- selectedCount: number;
+ stagedCount: number;
commitMessage: string;
onCommitMessageChange: (value: string) => void;
generatedHighlights: string[];
@@ -19,12 +19,13 @@ interface CommitSectionProps {
onCommit: () => void;
onCommitAndPush: () => void;
commitAction: CommitAction;
+ hasPendingIndexMutation?: boolean;
gitmojiEnabled: boolean;
onOpenGitmojiPicker: () => void;
}
export const CommitSection: React.FC = ({
- selectedCount,
+ stagedCount,
commitMessage,
onCommitMessageChange,
generatedHighlights,
@@ -34,31 +35,31 @@ export const CommitSection: React.FC = ({
onCommit,
onCommitAndPush,
commitAction,
+ hasPendingIndexMutation = false,
gitmojiEnabled,
onOpenGitmojiPicker,
}) => {
const { t } = useI18n();
- const hasSelectedFiles = selectedCount > 0;
- const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
+ const hasStagedFiles = stagedCount > 0;
+ const canCommit = commitMessage.trim() && hasStagedFiles && commitAction === null && !hasPendingIndexMutation;
const { isMobile, hasTouchInput } = useDeviceInfo();
const containerClassName = 'border-0 bg-transparent rounded-none';
- const headerClassName = 'flex w-full items-center justify-between px-0 pt-2 pb-1';
+ const headerClassName = 'flex w-full items-baseline gap-2 px-0 pt-2 pb-1';
const contentClassName = 'flex flex-col gap-3 px-0 pt-1 pb-3';
return (
{t('gitView.commit.title')}
+ {!hasStagedFiles ? (
+
+ {t('gitView.commit.stageFilesHint')}
+
+ ) : null}
- {!hasSelectedFiles ? (
-
- {t('gitView.commit.selectFilesHint')}
-
- ) : null}
-
= ({
disabled={
isGeneratingMessage ||
commitAction !== null ||
- selectedCount === 0
+ hasPendingIndexMutation ||
+ stagedCount === 0
}
type="button"
aria-label={t('gitView.commit.generateAria')}
diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx
index 40eefa35..dd8cdf8b 100644
--- a/packages/ui/src/components/views/git/GitHeader.tsx
+++ b/packages/ui/src/components/views/git/GitHeader.tsx
@@ -38,6 +38,7 @@ interface GitHeaderProps {
isApplyingIdentity: boolean;
isWorktreeMode: boolean;
onOpenHistory?: () => void;
+ onOpenStashes?: () => void;
actionTabItems?: SortableTabsStripItem[];
activeActionTab?: string;
onSelectActionTab?: (tabID: string) => void;
@@ -197,6 +198,7 @@ export const GitHeader: React.FC = ({
isApplyingIdentity,
isWorktreeMode,
onOpenHistory,
+ onOpenStashes,
actionTabItems,
activeActionTab,
onSelectActionTab,
@@ -208,20 +210,38 @@ export const GitHeader: React.FC = ({
const managementButtons = (
- {onOpenHistory ? (
-
-
-
-
- {t('gitView.history.title')}
-
+ {onOpenHistory || onOpenStashes ? (
+
+
+
+
+
+
+
+ {t('gitView.history.title')}
+
+
+ {onOpenHistory ? (
+
+
+ {t('gitView.history.title')}
+
+ ) : null}
+ {onOpenStashes ? (
+
+
+ {t('gitView.stashes.title')}
+
+ ) : null}
+
+
) : null}
);
diff --git a/packages/ui/src/components/views/git/StashesDialog.tsx b/packages/ui/src/components/views/git/StashesDialog.tsx
index d4fef84e..979a6fa4 100644
--- a/packages/ui/src/components/views/git/StashesDialog.tsx
+++ b/packages/ui/src/components/views/git/StashesDialog.tsx
@@ -15,8 +15,9 @@ interface StashesDialogProps {
onOpenChange: (open: boolean) => void;
directory: string | null;
hasUncommittedChanges: boolean;
+ hasStagedChanges?: boolean;
uncommittedFileCount: number;
- onChanged?: () => void | Promise;
+ onChanged?: (change?: { affectsIndex?: boolean }) => void | Promise;
}
type StashOperation = 'create' | `apply:${string}` | `pop:${string}` | `drop:${string}` | null;
@@ -26,6 +27,7 @@ export const StashesDialog: React.FC = ({
onOpenChange,
directory,
hasUncommittedChanges,
+ hasStagedChanges = false,
uncommittedFileCount,
onChanged,
}) => {
@@ -73,9 +75,9 @@ export const StashesDialog: React.FC = ({
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
}, [query, stashes]);
- const refreshAfterChange = React.useCallback(async () => {
+ const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
await load();
- await onChanged?.();
+ await onChanged?.(change);
}, [load, onChanged]);
const handleCreate = async () => {
@@ -89,7 +91,7 @@ export const StashesDialog: React.FC = ({
} else {
toast.info(t('gitView.stashes.toast.noChanges'));
}
- await refreshAfterChange();
+ await refreshAfterChange({ affectsIndex: Boolean(result.created && hasStagedChanges) });
} catch (error) {
toast.error(error instanceof Error ? error.message : t('gitView.stashes.toast.createFailed'));
} finally {
@@ -107,11 +109,11 @@ export const StashesDialog: React.FC = ({
if (kind === 'drop') await dropGitStash(directory, { ref: stash.ref });
const successKey = kind === 'apply' ? 'gitView.stashes.toast.applySuccess' : kind === 'pop' ? 'gitView.stashes.toast.popSuccess' : 'gitView.stashes.toast.dropSuccess';
toast.success(t(successKey));
- await refreshAfterChange();
+ await refreshAfterChange({ affectsIndex: kind !== 'drop' });
} catch (error) {
const failedKey = kind === 'apply' ? 'gitView.stashes.toast.applyFailed' : kind === 'pop' ? 'gitView.stashes.toast.popFailed' : 'gitView.stashes.toast.dropFailed';
toast.error(error instanceof Error ? error.message : t(failedKey));
- await refreshAfterChange();
+ await refreshAfterChange({ affectsIndex: kind !== 'drop' });
} finally {
setOperation(null);
}
diff --git a/packages/ui/src/components/views/git/changesTree.ts b/packages/ui/src/components/views/git/changesTree.ts
new file mode 100644
index 00000000..bf3d6fd0
--- /dev/null
+++ b/packages/ui/src/components/views/git/changesTree.ts
@@ -0,0 +1,113 @@
+import type { GitStatus } from '@/lib/api/types';
+
+export const TREE_INDENT_PX = 14;
+
+export type ChangesTreeDirectoryNode = {
+ id: string;
+ path: string;
+ name: string;
+ children: Map;
+ directFiles: GitStatus['files'];
+ files: GitStatus['files'];
+};
+
+export type FlattenedTreeRow =
+ | {
+ key: string;
+ kind: 'directory';
+ depth: number;
+ directory: ChangesTreeDirectoryNode;
+ }
+ | {
+ key: string;
+ kind: 'file';
+ depth: number;
+ file: GitStatus['files'][number];
+ };
+
+export const normalizePathForTree = (value: string): string =>
+ value.replace(/\\/g, '/').replace(/^\/+/, '').trim();
+
+const createDirectoryNode = (path: string, name: string): ChangesTreeDirectoryNode => ({
+ id: `dir:${path}`,
+ path,
+ name,
+ children: new Map(),
+ directFiles: [],
+ files: [],
+});
+
+export const buildChangesTree = (entries: GitStatus['files']): ChangesTreeDirectoryNode => {
+ const root = createDirectoryNode('', '');
+
+ for (const file of entries) {
+ const normalized = normalizePathForTree(file.path);
+ if (!normalized) {
+ continue;
+ }
+
+ const segments = normalized.split('/').filter(Boolean);
+ const directorySegments = segments.slice(0, -1);
+ let current = root;
+ current.files.push(file);
+
+ if (directorySegments.length > 0) {
+ let currentPath = '';
+ for (const segment of directorySegments) {
+ currentPath = currentPath ? `${currentPath}/${segment}` : segment;
+ const existing = current.children.get(segment);
+ if (existing) {
+ existing.files.push(file);
+ current = existing;
+ continue;
+ }
+
+ const created = createDirectoryNode(currentPath, segment);
+ created.files.push(file);
+ current.children.set(segment, created);
+ current = created;
+ }
+ }
+
+ current.directFiles.push(file);
+ }
+
+ return root;
+};
+
+export const flattenChangesTree = (
+ root: ChangesTreeDirectoryNode,
+ expandedDirectories: Set,
+): FlattenedTreeRow[] => {
+ const rows: FlattenedTreeRow[] = [];
+
+ const walk = (node: ChangesTreeDirectoryNode, depth: number) => {
+ const directories = Array.from(node.children.values()).sort((a, b) => a.path.localeCompare(b.path));
+ for (const directory of directories) {
+ rows.push({
+ key: directory.id,
+ kind: 'directory',
+ depth,
+ directory,
+ });
+
+ if (expandedDirectories.has(directory.path)) {
+ walk(directory, depth + 1);
+ }
+ }
+
+ const directFiles = [...node.directFiles].sort((a, b) => a.path.localeCompare(b.path));
+
+ for (const file of directFiles) {
+ rows.push({
+ key: `file:${normalizePathForTree(file.path)}`,
+ kind: 'file',
+ depth,
+ file,
+ });
+ }
+ };
+
+ walk(root, 0);
+ return rows;
+};
diff --git a/packages/ui/src/components/views/git/gitIndexMutationQueue.test.ts b/packages/ui/src/components/views/git/gitIndexMutationQueue.test.ts
new file mode 100644
index 00000000..c76315a2
--- /dev/null
+++ b/packages/ui/src/components/views/git/gitIndexMutationQueue.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, test } from 'bun:test';
+import { createGitIndexMutationQueue, type GitIndexMutationDirection } from './gitIndexMutationQueue';
+
+type Deferred = {
+ promise: Promise;
+ resolve: (value: T) => void;
+ reject: (error: unknown) => void;
+};
+
+const createDeferred = (): Deferred => {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+};
+
+const waitMicrotask = async () => {
+ await Promise.resolve();
+};
+
+describe('createGitIndexMutationQueue', () => {
+ test('coalesces consecutive mutations with the same directory and direction', async () => {
+ const calls: Array<{ direction: GitIndexMutationDirection; paths: string[] }> = [];
+ const queue = createGitIndexMutationQueue({
+ runMutation: async ({ direction, paths }) => {
+ calls.push({ direction, paths });
+ },
+ onMutationComplete: () => {},
+ onMutationError: () => {},
+ onPathsComplete: () => {},
+ scheduleFlush: () => queue.flush(),
+ });
+
+ queue.enqueue({ directory: '/repo', direction: 'stage', paths: new Set(['a.ts']) });
+ queue.enqueue({ directory: '/repo', direction: 'stage', paths: new Set(['b.ts', 'a.ts']) });
+ queue.flush();
+ await waitMicrotask();
+
+ expect(calls).toEqual([{ direction: 'stage', paths: ['a.ts', 'b.ts'] }]);
+ });
+
+ test('serializes mutations and preserves alternating direction order', async () => {
+ const first = createDeferred();
+ const calls: Array<{ direction: GitIndexMutationDirection; paths: string[] }> = [];
+ let callCount = 0;
+
+ const queue = createGitIndexMutationQueue({
+ runMutation: ({ direction, paths }) => {
+ calls.push({ direction, paths });
+ callCount += 1;
+ return callCount === 1 ? first.promise : Promise.resolve();
+ },
+ onMutationComplete: () => {},
+ onMutationError: () => {},
+ onPathsComplete: () => {},
+ scheduleFlush: () => queue.flush(),
+ });
+
+ queue.enqueue({ directory: '/repo', direction: 'stage', paths: new Set(['a.ts']) });
+ queue.enqueue({ directory: '/repo', direction: 'unstage', paths: new Set(['a.ts']) });
+ queue.flush();
+ queue.flush();
+ await waitMicrotask();
+
+ expect(calls).toEqual([{ direction: 'stage', paths: ['a.ts'] }]);
+ expect(queue.isRunning()).toBe(true);
+
+ first.resolve();
+ await waitMicrotask();
+ await waitMicrotask();
+
+ expect(calls).toEqual([
+ { direction: 'stage', paths: ['a.ts'] },
+ { direction: 'unstage', paths: ['a.ts'] },
+ ]);
+ });
+
+ test('reports errors, completes paths, and continues the queue', async () => {
+ const errors: unknown[] = [];
+ const completedPaths: string[][] = [];
+ const completedDirections: GitIndexMutationDirection[] = [];
+ let callCount = 0;
+
+ const queue = createGitIndexMutationQueue({
+ runMutation: async ({ direction }) => {
+ callCount += 1;
+ if (callCount === 1) {
+ throw new Error(`${direction} failed`);
+ }
+ },
+ onMutationComplete: ({ direction }) => {
+ completedDirections.push(direction);
+ },
+ onMutationError: (_mutation, error) => {
+ errors.push(error);
+ },
+ onPathsComplete: (paths) => {
+ completedPaths.push(paths);
+ },
+ scheduleFlush: () => queue.flush(),
+ });
+
+ queue.enqueue({ directory: '/repo', direction: 'stage', paths: new Set(['a.ts']) });
+ queue.enqueue({ directory: '/repo', direction: 'unstage', paths: new Set(['b.ts']) });
+ queue.flush();
+ await waitMicrotask();
+ await waitMicrotask();
+
+ expect(errors).toHaveLength(1);
+ expect(completedDirections).toEqual(['unstage']);
+ expect(completedPaths).toEqual([['a.ts'], ['b.ts']]);
+ });
+
+ test('passes rollback callbacks to error handlers', async () => {
+ let rollbackCalled = false;
+ const queue = createGitIndexMutationQueue({
+ runMutation: async () => {
+ throw new Error('stage failed');
+ },
+ onMutationComplete: () => {},
+ onMutationError: (mutation) => {
+ mutation.rollback?.();
+ },
+ onPathsComplete: () => {},
+ scheduleFlush: () => queue.flush(),
+ });
+
+ queue.enqueue({
+ directory: '/repo',
+ direction: 'stage',
+ paths: new Set(['a.ts']),
+ rollback: () => {
+ rollbackCalled = true;
+ },
+ });
+ queue.flush();
+ await waitMicrotask();
+
+ expect(rollbackCalled).toBe(true);
+ });
+});
diff --git a/packages/ui/src/components/views/git/gitIndexMutationQueue.ts b/packages/ui/src/components/views/git/gitIndexMutationQueue.ts
new file mode 100644
index 00000000..737130ca
--- /dev/null
+++ b/packages/ui/src/components/views/git/gitIndexMutationQueue.ts
@@ -0,0 +1,94 @@
+export type GitIndexMutationDirection = 'stage' | 'unstage';
+
+export type QueuedGitIndexMutation = {
+ directory: string;
+ direction: GitIndexMutationDirection;
+ paths: Set;
+ rollback?: () => void;
+};
+
+type MutationSnapshot = {
+ directory: string;
+ direction: GitIndexMutationDirection;
+ paths: string[];
+ rollback?: () => void;
+};
+
+type GitIndexMutationQueueOptions = {
+ runMutation: (mutation: MutationSnapshot) => Promise;
+ onMutationComplete: (mutation: MutationSnapshot) => void;
+ onMutationError: (mutation: MutationSnapshot, error: unknown) => void;
+ onPathsComplete: (paths: string[]) => void;
+ scheduleFlush: () => void;
+};
+
+export type GitIndexMutationQueue = {
+ enqueue: (mutation: QueuedGitIndexMutation) => void;
+ flush: () => void;
+ clear: () => void;
+ size: () => number;
+ isRunning: () => boolean;
+};
+
+export const createGitIndexMutationQueue = ({
+ runMutation,
+ onMutationComplete,
+ onMutationError,
+ onPathsComplete,
+ scheduleFlush,
+}: GitIndexMutationQueueOptions): GitIndexMutationQueue => {
+ const queuedMutations: QueuedGitIndexMutation[] = [];
+ let running = false;
+
+ const flush = () => {
+ if (running) {
+ return;
+ }
+
+ const nextMutation = queuedMutations.shift();
+ if (!nextMutation) {
+ return;
+ }
+
+ running = true;
+ const snapshot: MutationSnapshot = {
+ directory: nextMutation.directory,
+ direction: nextMutation.direction,
+ paths: Array.from(nextMutation.paths),
+ rollback: nextMutation.rollback,
+ };
+
+ void (async () => {
+ try {
+ await runMutation(snapshot);
+ onMutationComplete(snapshot);
+ } catch (error) {
+ onMutationError(snapshot, error);
+ } finally {
+ onPathsComplete(snapshot.paths);
+ running = false;
+ if (queuedMutations.length > 0) {
+ scheduleFlush();
+ }
+ }
+ })();
+ };
+
+ return {
+ enqueue: (mutation) => {
+ const lastMutation = queuedMutations[queuedMutations.length - 1];
+ if (lastMutation?.directory === mutation.directory && lastMutation.direction === mutation.direction) {
+ mutation.paths.forEach((path) => lastMutation.paths.add(path));
+ return;
+ }
+
+ queuedMutations.push(mutation);
+ },
+ flush,
+ clear: () => {
+ queuedMutations.length = 0;
+ },
+ size: () => queuedMutations.length,
+ isRunning: () => running,
+ };
+};
diff --git a/packages/ui/src/components/views/git/index.ts b/packages/ui/src/components/views/git/index.ts
index b94fec98..3709d3c6 100644
--- a/packages/ui/src/components/views/git/index.ts
+++ b/packages/ui/src/components/views/git/index.ts
@@ -1,6 +1,7 @@
export { GitHeader } from './GitHeader';
export { GitEmptyState } from './GitEmptyState';
-export { ChangesSection } from './ChangesSection';
+export { ChangesPanel } from './ChangesPanel';
+export type { ChangesGroupConfig } from './ChangesPanel';
export { ChangeRow } from './ChangeRow';
export { CommitSection } from './CommitSection';
export { CommitInput } from './CommitInput';
diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts
index 49a4b67e..747783ba 100644
--- a/packages/ui/src/lib/api/types.ts
+++ b/packages/ui/src/lib/api/types.ts
@@ -388,6 +388,7 @@ export interface GitRemoveRemotePayload {
export interface CreateGitCommitOptions {
addAll?: boolean;
files?: string[];
+ stageFiles?: string[];
}
export interface GitLogOptions {
@@ -421,7 +422,11 @@ export interface GitAPI {
getGitStatus(directory: string, options?: { mode?: 'light' }): Promise;
getGitDiff(directory: string, options: GetGitDiffOptions): Promise;
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise;
- revertGitFile(directory: string, filePath: string): Promise;
+ revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise;
+ stageGitFile(directory: string, filePath: string): Promise;
+ stageGitFiles?(directory: string, filePaths: string[]): Promise;
+ unstageGitFile(directory: string, filePath: string): Promise;
+ unstageGitFiles?(directory: string, filePaths: string[]): Promise;
isLinkedWorktree(directory: string): Promise;
getGitBranches(directory: string): Promise;
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
diff --git a/packages/ui/src/lib/gitApi.test.ts b/packages/ui/src/lib/gitApi.test.ts
index c901af76..88619d57 100644
--- a/packages/ui/src/lib/gitApi.test.ts
+++ b/packages/ui/src/lib/gitApi.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { GitAPI, GitStatus } from "./api/types"
-import { getGitStatus } from "./gitApi"
+import { getGitStatus, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from "./gitApi"
const status: GitStatus = {
current: "main",
@@ -48,3 +48,65 @@ describe("getGitStatus", () => {
expect(received).toEqual({ directory: "/repo", options: { mode: "light" } })
})
})
+
+describe("git index mutations", () => {
+ test("forwards bulk stage requests to runtime git APIs", async () => {
+ let received: { directory: string; paths: string[] } | null = null
+ const runtimeGit = {
+ stageGitFiles: async (directory: string, paths: string[]) => {
+ received = { directory, paths }
+ },
+ } as Partial as GitAPI
+
+ await withRuntimeGit(runtimeGit, async () => {
+ await stageGitFiles("/repo", ["a.ts", "b.ts"])
+ })
+
+ expect(received).toEqual({ directory: "/repo", paths: ["a.ts", "b.ts"] })
+ })
+
+ test("forwards bulk unstage requests to runtime git APIs", async () => {
+ let received: { directory: string; paths: string[] } | null = null
+ const runtimeGit = {
+ unstageGitFiles: async (directory: string, paths: string[]) => {
+ received = { directory, paths }
+ },
+ } as Partial as GitAPI
+
+ await withRuntimeGit(runtimeGit, async () => {
+ await unstageGitFiles("/repo", ["a.ts", "b.ts"])
+ })
+
+ expect(received).toEqual({ directory: "/repo", paths: ["a.ts", "b.ts"] })
+ })
+
+ test("keeps single-file stage wrapper routed to runtime single-file API", async () => {
+ let received: { directory: string; path: string } | null = null
+ const runtimeGit = {
+ stageGitFile: async (directory: string, path: string) => {
+ received = { directory, path }
+ },
+ } as Partial as GitAPI
+
+ await withRuntimeGit(runtimeGit, async () => {
+ await stageGitFile("/repo", "a.ts")
+ })
+
+ expect(received).toEqual({ directory: "/repo", path: "a.ts" })
+ })
+
+ test("keeps single-file unstage wrapper routed to runtime single-file API", async () => {
+ let received: { directory: string; path: string } | null = null
+ const runtimeGit = {
+ unstageGitFile: async (directory: string, path: string) => {
+ received = { directory, path }
+ },
+ } as Partial as GitAPI
+
+ await withRuntimeGit(runtimeGit, async () => {
+ await unstageGitFile("/repo", "a.ts")
+ })
+
+ expect(received).toEqual({ directory: "/repo", path: "a.ts" })
+ })
+})
diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts
index 2a922ff1..85e47c6d 100644
--- a/packages/ui/src/lib/gitApi.ts
+++ b/packages/ui/src/lib/gitApi.ts
@@ -126,10 +126,38 @@ export async function getGitFileDiff(
return gitHttp.getGitFileDiff(directory, options);
}
-export async function revertGitFile(directory: string, filePath: string): Promise {
+export async function revertGitFile(
+ directory: string,
+ filePath: string,
+ options?: { scope?: 'all' | 'working' }
+): Promise {
const runtime = getRuntimeGit();
- if (runtime) return runtime.revertGitFile(directory, filePath);
- return gitHttp.revertGitFile(directory, filePath);
+ if (runtime) return runtime.revertGitFile(directory, filePath, options);
+ return gitHttp.revertGitFile(directory, filePath, options);
+}
+
+export async function stageGitFile(directory: string, filePath: string): Promise {
+ const runtime = getRuntimeGit();
+ if (runtime?.stageGitFile) return runtime.stageGitFile(directory, filePath);
+ return gitHttp.stageGitFile(directory, filePath);
+}
+
+export async function stageGitFiles(directory: string, filePaths: string[]): Promise {
+ const runtime = getRuntimeGit();
+ if (runtime?.stageGitFiles) return runtime.stageGitFiles(directory, filePaths);
+ return gitHttp.stageGitFiles(directory, filePaths);
+}
+
+export async function unstageGitFile(directory: string, filePath: string): Promise {
+ const runtime = getRuntimeGit();
+ if (runtime?.unstageGitFile) return runtime.unstageGitFile(directory, filePath);
+ return gitHttp.unstageGitFile(directory, filePath);
+}
+
+export async function unstageGitFiles(directory: string, filePaths: string[]): Promise {
+ const runtime = getRuntimeGit();
+ if (runtime?.unstageGitFiles) return runtime.unstageGitFiles(directory, filePaths);
+ return gitHttp.unstageGitFiles(directory, filePaths);
}
export async function isLinkedWorktree(directory: string): Promise {
diff --git a/packages/ui/src/lib/gitApiHttp.test.ts b/packages/ui/src/lib/gitApiHttp.test.ts
new file mode 100644
index 00000000..441d8002
--- /dev/null
+++ b/packages/ui/src/lib/gitApiHttp.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, test } from 'bun:test';
+import { stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from './gitApiHttp';
+
+type FetchCall = {
+ input: RequestInfo | URL;
+ init?: RequestInit;
+};
+
+const previousFetch = globalThis.fetch;
+const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
+
+const installFetchMock = () => {
+ const calls: FetchCall[] = [];
+ globalThis.fetch = (async (input, init) => {
+ calls.push({ input, init });
+ return new Response(JSON.stringify({ success: true }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }) as typeof fetch;
+ return calls;
+};
+
+const installWindowMock = () => {
+ Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ value: {
+ location: { origin: 'http://localhost:3000' },
+ },
+ });
+};
+
+const restoreMocks = () => {
+ globalThis.fetch = previousFetch;
+ if (previousWindowDescriptor) {
+ Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
+ } else {
+ delete (globalThis as { window?: Window }).window;
+ }
+};
+
+const captureError = async (callback: () => Promise): Promise => {
+ try {
+ await callback();
+ return null;
+ } catch (error) {
+ return error;
+ }
+};
+
+describe('gitApiHttp index mutations', () => {
+ test('sends bulk stage payloads as paths', async () => {
+ installWindowMock();
+ const calls = installFetchMock();
+ try {
+ await stageGitFiles('/repo', ['a.ts', 'b.ts']);
+
+ expect(calls).toHaveLength(1);
+ expect(String(calls[0].input)).toBe('http://localhost:3000/api/git/stage?directory=%2Frepo');
+ expect(calls[0].init?.method).toBe('POST');
+ expect(JSON.parse(String(calls[0].init?.body))).toEqual({ paths: ['a.ts', 'b.ts'] });
+ } finally {
+ restoreMocks();
+ }
+ });
+
+ test('sends bulk unstage payloads as paths', async () => {
+ installWindowMock();
+ const calls = installFetchMock();
+ try {
+ await unstageGitFiles('/repo', ['a.ts', 'b.ts']);
+
+ expect(calls).toHaveLength(1);
+ expect(String(calls[0].input)).toBe('http://localhost:3000/api/git/unstage?directory=%2Frepo');
+ expect(calls[0].init?.method).toBe('POST');
+ expect(JSON.parse(String(calls[0].init?.body))).toEqual({ paths: ['a.ts', 'b.ts'] });
+ } finally {
+ restoreMocks();
+ }
+ });
+
+ test('single-file helpers use the bulk paths payload shape', async () => {
+ installWindowMock();
+ const calls = installFetchMock();
+ try {
+ await stageGitFile('/repo', 'a.ts');
+ await unstageGitFile('/repo', 'b.ts');
+
+ expect(JSON.parse(String(calls[0].init?.body))).toEqual({ paths: ['a.ts'] });
+ expect(JSON.parse(String(calls[1].init?.body))).toEqual({ paths: ['b.ts'] });
+ } finally {
+ restoreMocks();
+ }
+ });
+
+ test('rejects empty bulk path lists before fetching', async () => {
+ installWindowMock();
+ const calls = installFetchMock();
+ try {
+ const stageError = await captureError(() => stageGitFiles('/repo', [' ', '']));
+ const unstageError = await captureError(() => unstageGitFiles('/repo', []));
+
+ expect(stageError).toBeInstanceOf(Error);
+ expect((stageError as Error).message).toBe('path is required to stage git changes');
+ expect(unstageError).toBeInstanceOf(Error);
+ expect((unstageError as Error).message).toBe('path is required to unstage git changes');
+ expect(calls).toHaveLength(0);
+ } finally {
+ restoreMocks();
+ }
+ });
+});
diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts
index 4df16c7a..a3d4177b 100644
--- a/packages/ui/src/lib/gitApiHttp.ts
+++ b/packages/ui/src/lib/gitApiHttp.ts
@@ -200,7 +200,11 @@ export async function getGitFileDiff(directory: string, options: GetGitFileDiffO
return response.json();
}
-export async function revertGitFile(directory: string, filePath: string): Promise {
+export async function revertGitFile(
+ directory: string,
+ filePath: string,
+ options?: { scope?: 'all' | 'working' }
+): Promise {
if (!filePath) {
throw new Error('path is required to revert git changes');
}
@@ -208,7 +212,7 @@ export async function revertGitFile(directory: string, filePath: string): Promis
const response = await fetch(buildUrl(`${API_BASE}/revert`, directory), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path: filePath }),
+ body: JSON.stringify({ path: filePath, scope: options?.scope }),
});
if (!response.ok) {
@@ -219,6 +223,52 @@ export async function revertGitFile(directory: string, filePath: string): Promis
}
}
+export async function stageGitFile(directory: string, filePath: string): Promise {
+ await stageGitFiles(directory, [filePath]);
+}
+
+export async function stageGitFiles(directory: string, filePaths: string[]): Promise {
+ const paths = filePaths.map((path) => path.trim()).filter(Boolean);
+
+ if (paths.length === 0) {
+ throw new Error('path is required to stage git changes');
+ }
+
+ const response = await fetch(buildUrl(`${API_BASE}/stage`, directory), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paths }),
+ });
+
+ if (!response.ok) {
+ const message = await response.json().catch(() => ({ error: response.statusText }));
+ throw new Error(message.error || 'Failed to stage git changes');
+ }
+}
+
+export async function unstageGitFile(directory: string, filePath: string): Promise {
+ await unstageGitFiles(directory, [filePath]);
+}
+
+export async function unstageGitFiles(directory: string, filePaths: string[]): Promise {
+ const paths = filePaths.map((path) => path.trim()).filter(Boolean);
+
+ if (paths.length === 0) {
+ throw new Error('path is required to unstage git changes');
+ }
+
+ const response = await fetch(buildUrl(`${API_BASE}/unstage`, directory), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ paths }),
+ });
+
+ if (!response.ok) {
+ const message = await response.json().catch(() => ({ error: response.statusText }));
+ throw new Error(message.error || 'Failed to unstage git changes');
+ }
+}
+
export async function isLinkedWorktree(directory: string): Promise {
if (!directory) {
return false;
@@ -494,6 +544,7 @@ export async function createGitCommit(
message,
addAll: options.addAll ?? false,
files: options.files,
+ stageFiles: options.stageFiles,
}),
});
if (!response.ok) {
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts
index 8d3008c1..be61c3ae 100644
--- a/packages/ui/src/lib/i18n/messages/en.ts
+++ b/packages/ui/src/lib/i18n/messages/en.ts
@@ -451,8 +451,16 @@ export const dict = {
'gitView.changes.reverting': 'Reverting...',
'gitView.changes.selectAllAria': 'Select all files',
'gitView.changes.selectFileAria': 'Select File aria label',
+ 'gitView.changes.stagedTitle': 'Staged',
+ 'gitView.changes.resizeSplitAria': 'Resize staged and unstaged changes',
+ 'gitView.changes.stageAllAria': 'Stage all changes',
+ 'gitView.changes.stageDirectoryAria': 'Stage all changes in {path}',
+ 'gitView.changes.stageFileAria': 'Stage {path}',
'gitView.changes.title': 'Changes',
'gitView.changes.toggleDirectorySelectionAria': 'Toggle Directory Selection aria label',
+ 'gitView.changes.unstageAllAria': 'Unstage all changes',
+ 'gitView.changes.unstageDirectoryAria': 'Unstage all changes in {path}',
+ 'gitView.changes.unstageFileAria': 'Unstage {path}',
'gitView.commit.addGitmoji': 'Add gitmoji',
'gitView.commit.aiHighlights.insertAria': 'Insert aria label',
'gitView.commit.aiHighlights.insertTooltip': 'Insert tooltip',
@@ -467,6 +475,7 @@ export const dict = {
'gitView.commit.pushAria': 'Commit and sync',
'gitView.commit.pushing': 'Syncing...',
'gitView.commit.selectFilesHint': 'Select files in Changes to enable commit.',
+ 'gitView.commit.stageFilesHint': 'Stage files to enable commit.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'Cancel',
'gitView.common.close': 'Close',
@@ -775,15 +784,21 @@ export const dict = {
'gitView.toast.revertedFilesSingle': 'Reverted {count} file',
'gitView.toast.revertedSomePlural': 'Reverted {success} files, {failed} failed',
'gitView.toast.revertedSomeSingle': 'Reverted {success} file, {failed} failed',
+ 'gitView.toast.stageFileFailed': 'Failed to stage changes',
+ 'gitView.toast.stageFileToCommit': 'Stage at least one file to commit',
+ 'gitView.toast.stageFileToDescribe': 'Stage at least one file to describe',
'gitView.toast.selectFileToCommit': 'Select at least one file to commit',
'gitView.toast.selectFileToDescribe': 'Select at least one file to describe',
'gitView.toast.stashedRestored': 'Stashed changes restored',
'gitView.toast.syncActionFailed': '{action} failed',
+ 'gitView.toast.unstageFileFailed': 'Failed to unstage changes',
'gitView.toast.upstreamSet': 'Set upstream for {branch} to {remote}',
'gitView.worktree.availableInWorktreeMode': 'Available only in worktree mode',
'contextPanel.mode.chat': 'Chat',
'contextPanel.mode.files': 'Files',
'contextPanel.mode.diff': 'Diff',
+ 'contextPanel.mode.stagedDiff': 'Staged Diff',
+ 'contextPanel.mode.workingDiff': 'Working Diff',
'contextPanel.mode.plan': 'Plan',
'contextPanel.mode.context': 'Context',
'contextPanel.mode.preview': 'Preview',
diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts
index 7ff2f98e..9266fc96 100644
--- a/packages/ui/src/lib/i18n/messages/es.ts
+++ b/packages/ui/src/lib/i18n/messages/es.ts
@@ -452,8 +452,16 @@ export const dict: Record = {
"gitView.changes.reverting": "Revertiendo...",
"gitView.changes.selectAllAria": "Seleccionar todos los archivos",
"gitView.changes.selectFileAria": "Seleccionar archivo",
+ "gitView.changes.stagedTitle": "Preparados",
+ "gitView.changes.resizeSplitAria": "Ajustar tamaño de cambios preparados y sin preparar",
+ "gitView.changes.stageAllAria": "Preparar todos los cambios",
+ "gitView.changes.stageDirectoryAria": "Preparar todos los cambios en {path}",
+ "gitView.changes.stageFileAria": "Preparar {path}",
"gitView.changes.title": "Cambios",
"gitView.changes.toggleDirectorySelectionAria": "Alternar selección de directorio",
+ "gitView.changes.unstageAllAria": "Quitar todos los cambios del área preparada",
+ "gitView.changes.unstageDirectoryAria": "Quitar del área preparada todos los cambios en {path}",
+ "gitView.changes.unstageFileAria": "Quitar {path} del área preparada",
"gitView.commit.addGitmoji": "Añadir gitmoji",
"gitView.commit.aiHighlights.insertAria": "Insertar",
"gitView.commit.aiHighlights.insertTooltip": "Insertar",
@@ -468,6 +476,7 @@ export const dict: Record = {
"gitView.commit.pushAria": "Commit and sync",
"gitView.commit.pushing": "Sincronizando...",
"gitView.commit.selectFilesHint": "Selecciona archivos en Cambios para habilitar el commit.",
+ "gitView.commit.stageFilesHint": "Prepara archivos para habilitar el commit.",
"gitView.commit.title": "Commit",
"gitView.common.cancel": "Cancelar",
"gitView.common.close": "Cerrar",
@@ -776,15 +785,21 @@ export const dict: Record = {
"gitView.toast.revertedFilesSingle": "{count} archivo revertido",
"gitView.toast.revertedSomePlural": "{success} archivos revertidos, {failed} fallidos",
"gitView.toast.revertedSomeSingle": "{success} archivo revertido, {failed} fallido",
+ "gitView.toast.stageFileFailed": "No se pudieron preparar los cambios",
+ "gitView.toast.stageFileToCommit": "Prepara al menos un archivo para el commit",
+ "gitView.toast.stageFileToDescribe": "Prepara al menos un archivo para describir",
"gitView.toast.selectFileToCommit": "Selecciona al menos un archivo para el commit",
"gitView.toast.selectFileToDescribe": "Selecciona al menos un archivo para describir",
"gitView.toast.stashedRestored": "Cambios del stash restaurados",
"gitView.toast.syncActionFailed": "{action} falló",
+ "gitView.toast.unstageFileFailed": "No se pudieron quitar los cambios del área preparada",
"gitView.toast.upstreamSet": "Upstream de {branch} configurado como {remote}",
"gitView.worktree.availableInWorktreeMode": "Disponible solo en modo worktree",
"contextPanel.mode.chat": "Chat",
"contextPanel.mode.files": "Archivos",
"contextPanel.mode.diff": "Diff",
+ "contextPanel.mode.stagedDiff": "Staged Diff",
+ "contextPanel.mode.workingDiff": "Working Diff",
"contextPanel.mode.plan": "Plan",
"contextPanel.mode.context": "Contexto",
"contextPanel.mode.preview": "Vista previa",
diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts
index 9b97ad5d..9ae11556 100644
--- a/packages/ui/src/lib/i18n/messages/ko.ts
+++ b/packages/ui/src/lib/i18n/messages/ko.ts
@@ -452,8 +452,16 @@ export const dict: Record = {
'gitView.changes.reverting': '되돌리는 중…',
'gitView.changes.selectAllAria': '모든 파일 선택',
'gitView.changes.selectFileAria': '파일 선택',
+ 'gitView.changes.stagedTitle': '스테이징됨',
+ 'gitView.changes.resizeSplitAria': '스테이징 및 미스테이징 변경사항 크기 조정',
+ 'gitView.changes.stageAllAria': '모든 변경사항 스테이징',
+ 'gitView.changes.stageDirectoryAria': '{path}의 모든 변경사항 스테이징',
+ 'gitView.changes.stageFileAria': '{path} 스테이징',
'gitView.changes.title': '변경사항',
'gitView.changes.toggleDirectorySelectionAria': '디렉터리 선택 전환',
+ 'gitView.changes.unstageAllAria': '모든 변경사항 스테이징 해제',
+ 'gitView.changes.unstageDirectoryAria': '{path}의 모든 변경사항 스테이징 해제',
+ 'gitView.changes.unstageFileAria': '{path} 스테이징 해제',
'gitView.commit.addGitmoji': 'gitmoji 추가',
'gitView.commit.aiHighlights.insertAria': '커밋 메시지에 삽입',
'gitView.commit.aiHighlights.insertTooltip': '커밋 메시지에 삽입',
@@ -468,6 +476,7 @@ export const dict: Record = {
'gitView.commit.pushAria': 'Commit and sync',
'gitView.commit.pushing': 'sync 중…',
'gitView.commit.selectFilesHint': '커밋하려면 변경 사항에서 파일을 선택하세요.',
+ 'gitView.commit.stageFilesHint': '커밋하려면 파일을 스테이징하세요.',
'gitView.commit.title': '커밋',
'gitView.common.cancel': '취소',
'gitView.common.close': '닫기',
@@ -776,15 +785,21 @@ export const dict: Record = {
'gitView.toast.revertedFilesSingle': '파일 {count}개 되돌림',
'gitView.toast.revertedSomePlural': '파일 {success}개 되돌림, {failed}개 실패',
'gitView.toast.revertedSomeSingle': '파일 {success}개 되돌림, {failed}개 실패',
+ 'gitView.toast.stageFileFailed': '변경사항 스테이징 실패',
+ 'gitView.toast.stageFileToCommit': '커밋하려면 파일을 하나 이상 스테이징하세요',
+ 'gitView.toast.stageFileToDescribe': '설명하려면 파일을 하나 이상 스테이징하세요',
'gitView.toast.selectFileToCommit': '커밋할 파일을 하나 이상 선택하세요',
'gitView.toast.selectFileToDescribe': '설명할 파일을 하나 이상 선택하세요',
'gitView.toast.stashedRestored': 'stash한 변경 사항이 복원되었습니다',
'gitView.toast.syncActionFailed': '{action} 실패',
+ 'gitView.toast.unstageFileFailed': '변경사항 스테이징 해제 실패',
'gitView.toast.upstreamSet': '{branch}의 업스트림을 {remote}(으)로 설정했습니다',
'gitView.worktree.availableInWorktreeMode': '워크트리 모드에서만 사용할 수 있습니다',
'contextPanel.mode.chat': '채팅',
'contextPanel.mode.files': '파일',
'contextPanel.mode.diff': '변경사항',
+ 'contextPanel.mode.stagedDiff': 'Staged Diff',
+ 'contextPanel.mode.workingDiff': 'Working Diff',
'contextPanel.mode.plan': '계획',
'contextPanel.mode.context': '컨텍스트',
'contextPanel.mode.preview': '미리보기',
diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts
index a8ed24e0..d2279abf 100644
--- a/packages/ui/src/lib/i18n/messages/pl.ts
+++ b/packages/ui/src/lib/i18n/messages/pl.ts
@@ -1073,6 +1073,8 @@ export const dict: Record = {
'contextPanel.mode.chat': 'Chat',
'contextPanel.mode.context': 'Context',
'contextPanel.mode.diff': 'Różnice',
+ 'contextPanel.mode.stagedDiff': 'Staged Diff',
+ 'contextPanel.mode.workingDiff': 'Working Diff',
'contextPanel.mode.files': 'Pliki',
'contextPanel.mode.plan': 'Plan',
'contextPanel.mode.preview': 'Podgląd',
@@ -1421,8 +1423,16 @@ export const dict: Record = {
'gitView.changes.reverting': 'Cofanie...',
'gitView.changes.selectAllAria': 'Zaznacz wszystkie pliki',
'gitView.changes.selectFileAria': 'Zaznacz plik',
+ 'gitView.changes.stagedTitle': 'W indeksie',
+ 'gitView.changes.resizeSplitAria': 'Zmień rozmiar zmian w indeksie i poza indeksem',
+ 'gitView.changes.stageAllAria': 'Dodaj wszystkie zmiany do indeksu',
+ 'gitView.changes.stageDirectoryAria': 'Dodaj do indeksu wszystkie zmiany w {path}',
+ 'gitView.changes.stageFileAria': 'Dodaj {path} do indeksu',
'gitView.changes.title': 'Zmiany',
'gitView.changes.toggleDirectorySelectionAria': 'Przełącz zaznaczenie katalogu',
+ 'gitView.changes.unstageAllAria': 'Usuń wszystkie zmiany z indeksu',
+ 'gitView.changes.unstageDirectoryAria': 'Usuń z indeksu wszystkie zmiany w {path}',
+ 'gitView.changes.unstageFileAria': 'Usuń {path} z indeksu',
'gitView.commit.addGitmoji': 'Dodaj gitmoji',
'gitView.commit.aiHighlights.insertAria': 'Wstaw',
'gitView.commit.aiHighlights.insertTooltip': 'Wstaw',
@@ -1437,6 +1447,7 @@ export const dict: Record = {
'gitView.commit.pushAria': 'Wypchnij',
'gitView.commit.pushing': 'Wypychanie...',
'gitView.commit.selectFilesHint': 'Zaznacz pliki w sekcji Zmiany, aby włączyć commit.',
+ 'gitView.commit.stageFilesHint': 'Dodaj pliki do indeksu, aby włączyć commit.',
'gitView.commit.title': 'Commit',
'gitView.common.cancel': 'Anuluj',
'gitView.common.close': 'Zamknij',
@@ -1700,10 +1711,14 @@ export const dict: Record = {
'gitView.toast.revertedFilesSingle': 'Cofnięto {count} plik',
'gitView.toast.revertedSomePlural': 'Cofnięto {success} plików, {failed} nieudanych',
'gitView.toast.revertedSomeSingle': 'Cofnięto {success} plik, {failed} nieudanych',
+ 'gitView.toast.stageFileFailed': 'Nie udało się dodać zmian do indeksu',
+ 'gitView.toast.stageFileToCommit': 'Dodaj do indeksu co najmniej jeden plik do commita',
+ 'gitView.toast.stageFileToDescribe': 'Dodaj do indeksu co najmniej jeden plik do opisu',
'gitView.toast.selectFileToCommit': 'Zaznacz co najmniej jeden plik do commita',
'gitView.toast.selectFileToDescribe': 'Zaznacz co najmniej jeden plik do opisu',
'gitView.toast.stashedRestored': 'Przywrócono odłożone zmiany',
'gitView.toast.syncActionFailed': 'Operacja {action} nie powiodła się',
+ 'gitView.toast.unstageFileFailed': 'Nie udało się usunąć zmian z indeksu',
'gitView.toast.upstreamSet': 'Ustawiono upstream dla {branch} na {remote}',
'gitView.worktree.availableInWorktreeMode': 'Dostępne tylko w trybie drzewa pracy',
'header.actions.backAria': 'Wstecz',
diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts
index 6118313e..12af130a 100644
--- a/packages/ui/src/lib/i18n/messages/pt-BR.ts
+++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts
@@ -452,8 +452,16 @@ export const dict: Record = {
"gitView.changes.reverting": "Revertiendo...",
"gitView.changes.selectAllAria": "Selecionar todos os arquivos",
"gitView.changes.selectFileAria": "Selecionar arquivo",
+ "gitView.changes.stagedTitle": "Staged",
+ "gitView.changes.resizeSplitAria": "Ajustar tamanho das alterações staged e unstaged",
+ "gitView.changes.stageAllAria": "Adicionar todas as alterações ao stage",
+ "gitView.changes.stageDirectoryAria": "Adicionar todas as alterações em {path} ao stage",
+ "gitView.changes.stageFileAria": "Adicionar {path} ao stage",
"gitView.changes.title": "Alterações",
"gitView.changes.toggleDirectorySelectionAria": "Alternar selección de diretório",
+ "gitView.changes.unstageAllAria": "Remover todas as alterações do stage",
+ "gitView.changes.unstageDirectoryAria": "Remover todas as alterações em {path} do stage",
+ "gitView.changes.unstageFileAria": "Remover {path} do stage",
"gitView.commit.addGitmoji": "Adicionar gitmoji",
"gitView.commit.aiHighlights.insertAria": "Insertar",
"gitView.commit.aiHighlights.insertTooltip": "Insertar",
@@ -468,6 +476,7 @@ export const dict: Record = {
"gitView.commit.pushAria": "Commit and sync",
"gitView.commit.pushing": "Sincronizando...",
"gitView.commit.selectFilesHint": "Selecione arquivos em Alterações para habilitar o commit.",
+ "gitView.commit.stageFilesHint": "Adicione arquivos ao stage para habilitar o commit.",
"gitView.commit.title": "Commit",
"gitView.common.cancel": "Cancelar",
"gitView.common.close": "Fechar",
@@ -776,15 +785,21 @@ export const dict: Record = {
"gitView.toast.revertedFilesSingle": "{count} arquivo revertido",
"gitView.toast.revertedSomePlural": "{success} arquivos revertidos, {failed} com falha",
"gitView.toast.revertedSomeSingle": "{success} arquivo revertido, {failed} falhou",
+ "gitView.toast.stageFileFailed": "Não foi possível adicionar as alterações ao stage",
+ "gitView.toast.stageFileToCommit": "Adicione ao menos um arquivo ao stage para o commit",
+ "gitView.toast.stageFileToDescribe": "Adicione ao menos um arquivo ao stage para descrever",
"gitView.toast.selectFileToCommit": "Selecione ao menos um arquivo para o commit",
"gitView.toast.selectFileToDescribe": "Selecione ao menos um arquivo para descrever",
"gitView.toast.stashedRestored": "Alterações do stash restaurados",
"gitView.toast.syncActionFailed": "{action} falhou",
+ "gitView.toast.unstageFileFailed": "Não foi possível remover as alterações do stage",
"gitView.toast.upstreamSet": "Upstream de {branch} configurado como {remote}",
"gitView.worktree.availableInWorktreeMode": "Disponível apenas em modo worktree",
"contextPanel.mode.chat": "Chat",
"contextPanel.mode.files": "Arquivos",
"contextPanel.mode.diff": "Diff",
+ "contextPanel.mode.stagedDiff": "Staged Diff",
+ "contextPanel.mode.workingDiff": "Working Diff",
"contextPanel.mode.plan": "Plano",
"contextPanel.mode.context": "Contexto",
"contextPanel.mode.preview": "Prévia",
diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts
index ad3d4347..f4e27d1b 100644
--- a/packages/ui/src/lib/i18n/messages/uk.ts
+++ b/packages/ui/src/lib/i18n/messages/uk.ts
@@ -452,8 +452,16 @@ export const dict: Record = {
"gitView.changes.reverting": "Скасування...",
"gitView.changes.selectAllAria": "Вибрати всі файли",
"gitView.changes.selectFileAria": "Вибрати файл",
+ "gitView.changes.stagedTitle": "Індексовані",
+ "gitView.changes.resizeSplitAria": "Змінити розмір індексованих і неіндексованих змін",
+ "gitView.changes.stageAllAria": "Додати всі зміни до індексу",
+ "gitView.changes.stageDirectoryAria": "Додати до індексу всі зміни в {path}",
+ "gitView.changes.stageFileAria": "Додати {path} до індексу",
"gitView.changes.title": "Зміни",
"gitView.changes.toggleDirectorySelectionAria": "Перемкнути вибір каталогу",
+ "gitView.changes.unstageAllAria": "Прибрати всі зміни з індексу",
+ "gitView.changes.unstageDirectoryAria": "Прибрати з індексу всі зміни в {path}",
+ "gitView.changes.unstageFileAria": "Прибрати {path} з індексу",
"gitView.commit.addGitmoji": "Додати gitmoji",
"gitView.commit.aiHighlights.insertAria": "Вставити підказку",
"gitView.commit.aiHighlights.insertTooltip": "Вставити підказку",
@@ -468,6 +476,7 @@ export const dict: Record = {
"gitView.commit.pushAria": "Commit and sync",
"gitView.commit.pushing": "Sync...",
"gitView.commit.selectFilesHint": "Виберіть файли в розділі «Зміни», щоб увімкнути коміт.",
+ "gitView.commit.stageFilesHint": "Додайте файли до індексу, щоб увімкнути коміт.",
"gitView.commit.title": "Коміт",
"gitView.common.cancel": "Скасувати",
"gitView.common.close": "Закрити",
@@ -776,15 +785,21 @@ export const dict: Record = {
"gitView.toast.revertedFilesSingle": "Скасовано зміни у файлі: {count}",
"gitView.toast.revertedSomePlural": "Скасовано змін у файлах: {success}, не вдалося: {failed}",
"gitView.toast.revertedSomeSingle": "Скасовано змін у файлах: {success}, не вдалося: {failed}",
+ "gitView.toast.stageFileFailed": "Не вдалося додати зміни до індексу",
+ "gitView.toast.stageFileToCommit": "Додайте до індексу принаймні один файл для коміту",
+ "gitView.toast.stageFileToDescribe": "Додайте до індексу принаймні один файл для опису",
"gitView.toast.selectFileToCommit": "Виберіть принаймні один файл для коміту",
"gitView.toast.selectFileToDescribe": "Виберіть хоча б один файл для опису",
"gitView.toast.stashedRestored": "Зміни зі stash відновлено",
"gitView.toast.syncActionFailed": "{action} не вдалося",
+ "gitView.toast.unstageFileFailed": "Не вдалося прибрати зміни з індексу",
"gitView.toast.upstreamSet": "Upstream для {branch} встановлено на {remote}",
"gitView.worktree.availableInWorktreeMode": "Доступно лише в режимі worktree",
"contextPanel.mode.chat": "Чат",
"contextPanel.mode.files": "Файли",
"contextPanel.mode.diff": "Diff",
+ "contextPanel.mode.stagedDiff": "Staged Diff",
+ "contextPanel.mode.workingDiff": "Working Diff",
"contextPanel.mode.plan": "План",
"contextPanel.mode.context": "Контекст",
"contextPanel.mode.preview": "Перегляд",
diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts
index 2dc378bc..d66616ab 100644
--- a/packages/ui/src/lib/i18n/messages/zh-CN.ts
+++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts
@@ -452,8 +452,16 @@ export const dict: Record = {
'gitView.changes.reverting': '正在还原...',
'gitView.changes.selectAllAria': '全选文件',
'gitView.changes.selectFileAria': '选择 {path}',
+ 'gitView.changes.stagedTitle': '已暂存',
+ 'gitView.changes.resizeSplitAria': '调整已暂存和未暂存更改区域大小',
+ 'gitView.changes.stageAllAria': '暂存所有更改',
+ 'gitView.changes.stageDirectoryAria': '暂存 {path} 中的所有更改',
+ 'gitView.changes.stageFileAria': '暂存 {path}',
'gitView.changes.title': '更改',
'gitView.changes.toggleDirectorySelectionAria': '切换目录 {path} 的选择',
+ 'gitView.changes.unstageAllAria': '取消暂存所有更改',
+ 'gitView.changes.unstageDirectoryAria': '取消暂存 {path} 中的所有更改',
+ 'gitView.changes.unstageFileAria': '取消暂存 {path}',
'gitView.commit.addGitmoji': '添加 gitmoji',
'gitView.commit.aiHighlights.insertAria': '将高亮插入提交信息',
'gitView.commit.aiHighlights.insertTooltip': '将高亮追加到提交信息',
@@ -468,6 +476,7 @@ export const dict: Record = {
'gitView.commit.pushAria': '提交并同步',
'gitView.commit.pushing': '同步中...',
'gitView.commit.selectFilesHint': '在“更改”中选择文件以启用提交。',
+ 'gitView.commit.stageFilesHint': '暂存文件以启用提交。',
'gitView.commit.title': '提交',
'gitView.common.cancel': '取消',
'gitView.common.close': '关闭',
@@ -776,15 +785,21 @@ export const dict: Record = {
'gitView.toast.revertedFilesSingle': '已回退 {count} 个文件',
'gitView.toast.revertedSomePlural': '已回退 {success} 个文件,{failed} 个失败',
'gitView.toast.revertedSomeSingle': '已回退 {success} 个文件,{failed} 个失败',
+ 'gitView.toast.stageFileFailed': '暂存更改失败',
+ 'gitView.toast.stageFileToCommit': '请至少暂存一个文件再提交',
+ 'gitView.toast.stageFileToDescribe': '请至少暂存一个文件再生成描述',
'gitView.toast.selectFileToCommit': '请至少选择一个文件再提交',
'gitView.toast.selectFileToDescribe': '请至少选择一个文件再生成描述',
'gitView.toast.stashedRestored': '已恢复储藏的更改',
'gitView.toast.syncActionFailed': '{action} 失败',
+ 'gitView.toast.unstageFileFailed': '取消暂存更改失败',
'gitView.toast.upstreamSet': '已将 {branch} 的上游设置为 {remote}',
'gitView.worktree.availableInWorktreeMode': '仅在工作树模式下可用',
'contextPanel.mode.chat': '聊天',
'contextPanel.mode.files': '文件',
'contextPanel.mode.diff': '差异',
+ 'contextPanel.mode.stagedDiff': 'Staged Diff',
+ 'contextPanel.mode.workingDiff': 'Working Diff',
'contextPanel.mode.plan': '计划',
'contextPanel.mode.context': '上下文',
'contextPanel.mode.preview': '预览',
diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts
index 3ec3f971..6eb5c6fc 100644
--- a/packages/ui/src/stores/useGitStore.test.ts
+++ b/packages/ui/src/stores/useGitStore.test.ts
@@ -9,6 +9,7 @@ type Deferred = {
};
type GitAPI = Parameters['fetchStatus']>[1];
+type DirectoryGitState = NonNullable['getDirectoryState']>>;
const createDeferred = (): Deferred => {
let resolve!: (value: T) => void;
@@ -20,16 +21,44 @@ const createDeferred = (): Deferred => {
return { promise, resolve, reject };
};
-const createStatus = (diffStats?: GitStatus['diffStats']): GitStatus => ({
+const createStatus = (diffStats?: GitStatus['diffStats'], files: GitStatus['files'] = []): GitStatus => ({
current: 'main',
tracking: null,
ahead: 0,
behind: 0,
- files: [],
- isClean: true,
+ files,
+ isClean: files.length === 0,
diffStats,
});
+const createDirectoryState = (status: GitStatus): DirectoryGitState => ({
+ isGitRepo: true,
+ status,
+ branches: null,
+ log: null,
+ identity: null,
+ diffCache: new Map(),
+ indexRevision: 0,
+ lastRepoCheckAt: 0,
+ lastStatusFetch: 0,
+ lastStatusChange: 0,
+ lastLogFetch: 0,
+ lastBranchesFetch: 0,
+ lastIdentityFetch: 0,
+ logMaxCount: 25,
+ isLoadingStatus: false,
+ isLoadingLog: false,
+ isLoadingBranches: false,
+ isLoadingIdentity: false,
+});
+
+const setDirectoryStatus = (status: GitStatus) => {
+ useGitStore.setState({
+ directories: new Map([['/repo', createDirectoryState(status)]]),
+ activeDirectory: '/repo',
+ });
+};
+
const createGitApi = (getGitStatus: GitAPI['getGitStatus']): GitAPI => ({
checkIsGitRepository: async () => true,
getGitStatus,
@@ -91,4 +120,134 @@ describe('useGitStore', () => {
const [fullResult, lightResult] = await Promise.all([fullPromise, lightPromise]);
expect(lightResult).toBe(fullResult);
});
+
+ test('optimistically stages modified files and preserves untouched file references', () => {
+ const target = { path: 'src/index.ts', index: ' ', working_dir: 'M' };
+ const untouched = { path: 'README.md', index: ' ', working_dir: 'M' };
+ const initialStatus = createStatus(undefined, [target, untouched]);
+ setDirectoryStatus(initialStatus);
+
+ const previousStatus = useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'stage');
+ const status = useGitStore.getState().getDirectoryState('/repo')?.status;
+ const state = useGitStore.getState().getDirectoryState('/repo');
+
+ expect(previousStatus).toBe(initialStatus);
+ expect(status?.files).toEqual([
+ { path: 'src/index.ts', index: 'M', working_dir: ' ' },
+ untouched,
+ ]);
+ expect(status?.files[1]).toBe(untouched);
+ expect(state?.indexRevision).toBe(1);
+ });
+
+ test('optimistically stages untracked files as added files', () => {
+ setDirectoryStatus(createStatus(undefined, [
+ { path: 'new-file.ts', index: '?', working_dir: '?' },
+ ]));
+
+ useGitStore.getState().moveStatusPathsOptimistically('/repo', ['new-file.ts'], 'stage');
+ const status = useGitStore.getState().getDirectoryState('/repo')?.status;
+
+ expect(status?.files).toEqual([
+ { path: 'new-file.ts', index: 'A', working_dir: ' ' },
+ ]);
+ });
+
+ test('optimistically unstages staged files', () => {
+ setDirectoryStatus(createStatus(undefined, [
+ { path: 'src/index.ts', index: 'M', working_dir: ' ' },
+ ]));
+
+ useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'unstage');
+ const status = useGitStore.getState().getDirectoryState('/repo')?.status;
+
+ expect(status?.files).toEqual([
+ { path: 'src/index.ts', index: ' ', working_dir: 'M' },
+ ]);
+ });
+
+ test('optimistically unstages staged added files back to untracked files', () => {
+ setDirectoryStatus(createStatus(undefined, [
+ { path: 'new-file.ts', index: 'A', working_dir: ' ' },
+ ]));
+
+ useGitStore.getState().moveStatusPathsOptimistically('/repo', ['new-file.ts'], 'unstage');
+ const status = useGitStore.getState().getDirectoryState('/repo')?.status;
+
+ expect(status?.files).toEqual([
+ { path: 'new-file.ts', index: ' ', working_dir: '?' },
+ ]);
+ });
+
+ test('keeps conflicted files unchanged during optimistic moves', () => {
+ const conflicted = { path: 'conflict.ts', index: 'U', working_dir: 'U' };
+ setDirectoryStatus(createStatus(undefined, [conflicted]));
+
+ useGitStore.getState().moveStatusPathsOptimistically('/repo', ['conflict.ts'], 'stage');
+ const status = useGitStore.getState().getDirectoryState('/repo')?.status;
+
+ expect(status?.files).toEqual([conflicted]);
+ expect(status?.files[0]).toBe(conflicted);
+ });
+
+ test('preserves diff stats during optimistic moves', () => {
+ const diffStats = { 'src/index.ts': { insertions: 2, deletions: 1 } };
+ setDirectoryStatus(createStatus(diffStats, [
+ { path: 'src/index.ts', index: ' ', working_dir: 'M' },
+ ]));
+
+ useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'stage');
+ const status = useGitStore.getState().getDirectoryState('/repo')?.status;
+
+ expect(status?.diffStats).toBe(diffStats);
+ });
+
+ test('does nothing when optimistic move has no matching path', () => {
+ const initialStatus = createStatus(undefined, [
+ { path: 'src/index.ts', index: ' ', working_dir: 'M' },
+ ]);
+ setDirectoryStatus(initialStatus);
+
+ const previousStatus = useGitStore.getState().moveStatusPathsOptimistically('/repo', ['missing.ts'], 'stage');
+
+ expect(previousStatus).toBe(initialStatus);
+ expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus);
+ expect(useGitStore.getState().getDirectoryState('/repo')?.indexRevision).toBe(0);
+ });
+
+ test('does nothing without status for optimistic moves', () => {
+ useGitStore.setState({
+ directories: new Map([['/repo', { ...createDirectoryState(createStatus()), status: null }]]),
+ activeDirectory: '/repo',
+ });
+
+ const previousStatus = useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'stage');
+
+ expect(previousStatus).toBeNull();
+ expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBeNull();
+ });
+
+ test('removes entries that become clean during optimistic moves', () => {
+ setDirectoryStatus(createStatus(undefined, [
+ { path: 'clean.ts', index: ' ', working_dir: ' ' },
+ ]));
+
+ useGitStore.getState().moveStatusPathsOptimistically('/repo', ['clean.ts'], 'stage');
+ const status = useGitStore.getState().getDirectoryState('/repo')?.status;
+
+ expect(status?.files).toEqual([]);
+ expect(status?.isClean).toBe(true);
+ });
+
+ test('restores previous status for optimistic rollback', () => {
+ const initialStatus = createStatus(undefined, [
+ { path: 'src/index.ts', index: ' ', working_dir: 'M' },
+ ]);
+ setDirectoryStatus(initialStatus);
+
+ const previousStatus = useGitStore.getState().moveStatusPathsOptimistically('/repo', ['src/index.ts'], 'stage');
+ useGitStore.getState().restoreStatus('/repo', previousStatus);
+
+ expect(useGitStore.getState().getDirectoryState('/repo')?.status).toBe(initialStatus);
+ });
});
diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts
index bac0ff12..d6dd611d 100644
--- a/packages/ui/src/stores/useGitStore.ts
+++ b/packages/ui/src/stores/useGitStore.ts
@@ -31,6 +31,7 @@ interface DirectoryGitState {
log: GitLogResponse | null;
identity: GitIdentitySummary | null;
diffCache: Map;
+ indexRevision: number;
lastRepoCheckAt: number;
lastStatusFetch: number;
lastStatusChange: number;
@@ -61,6 +62,9 @@ interface GitStore {
ensureStatus: (directory: string, git: GitAPI) => Promise;
ensureAll: (directory: string, git: GitAPI) => Promise;
+ moveStatusPathsOptimistically: (directory: string, paths: string[], direction: 'stage' | 'unstage') => GitStatus | null;
+ restoreStatus: (directory: string, status: GitStatus | null) => void;
+ bumpIndexRevision: (directory: string) => void;
getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null;
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
@@ -122,6 +126,7 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
log: null,
identity: null,
diffCache: new Map(),
+ indexRevision: 0,
lastRepoCheckAt: 0,
lastStatusFetch: 0,
lastStatusChange: 0,
@@ -278,6 +283,72 @@ const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus |
return changed;
};
+const hasIndexStatusChanged = (oldStatus: GitStatus | null, newStatus: GitStatus | null): boolean => {
+ if (!oldStatus && !newStatus) return false;
+ if (!oldStatus || !newStatus) return true;
+
+ const oldFiles = oldStatus.files ?? [];
+ const newFiles = newStatus.files ?? [];
+ const normalizeIndexStatus = (value?: string | null): string => {
+ const trimmed = value?.trim() ?? '';
+ return trimmed === '?' ? '' : trimmed;
+ };
+
+ const oldIndexByPath = new Map(oldFiles.map((file) => [file.path, normalizeIndexStatus(file.index)] as const));
+ const newIndexByPath = new Map(newFiles.map((file) => [file.path, normalizeIndexStatus(file.index)] as const));
+ const paths = new Set([...oldIndexByPath.keys(), ...newIndexByPath.keys()]);
+
+ for (const path of paths) {
+ if ((oldIndexByPath.get(path) ?? '') !== (newIndexByPath.get(path) ?? '')) {
+ return true;
+ }
+ }
+
+ return false;
+};
+
+const isBlankStatusCode = (value?: string | null): boolean => !value || value.trim().length === 0;
+const isConflictStatusCode = (value?: string | null): boolean => (value || '').trim() === 'U';
+
+const toStagedStatusFile = (file: GitStatus['files'][number]): GitStatus['files'][number] => {
+ const index = (file.index || '').trim();
+ const workingDir = (file.working_dir || '').trim();
+
+ if (isConflictStatusCode(index) || isConflictStatusCode(workingDir)) {
+ return file;
+ }
+
+ const nextIndex = index === '?' || workingDir === '?'
+ ? 'A'
+ : index || workingDir || ' ';
+
+ return {
+ ...file,
+ index: nextIndex,
+ working_dir: ' ',
+ };
+};
+
+const toUnstagedStatusFile = (file: GitStatus['files'][number]): GitStatus['files'][number] => {
+ const index = (file.index || '').trim();
+ const workingDir = (file.working_dir || '').trim();
+
+ if (isConflictStatusCode(index) || isConflictStatusCode(workingDir)) {
+ return file;
+ }
+
+ const nextWorkingDir = workingDir || (index === 'A' || index === '?' ? '?' : index) || ' ';
+
+ return {
+ ...file,
+ index: ' ',
+ working_dir: nextWorkingDir,
+ };
+};
+
+const isCleanStatusFile = (file: GitStatus['files'][number]): boolean =>
+ isBlankStatusCode(file.index) && isBlankStatusCode(file.working_dir);
+
export const useGitStore = create()(
devtools(
(set, get) => ({
@@ -369,6 +440,7 @@ export const useGitStore = create()(
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
+ const indexStatusChanged = hasIndexStatusChanged(currentDirState.status, newStatus);
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
@@ -402,6 +474,7 @@ export const useGitStore = create()(
isGitRepo: true,
status: mergedStatus,
diffCache: nextDiffCache,
+ indexRevision: indexStatusChanged ? currentDirState.indexRevision + 1 : currentDirState.indexRevision,
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
lastStatusFetch: Date.now(),
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
@@ -445,6 +518,95 @@ export const useGitStore = create()(
}
},
+ moveStatusPathsOptimistically: (directory, paths, direction) => {
+ const normalizedPaths = new Set(paths.map((path) => path.trim()).filter(Boolean));
+ if (normalizedPaths.size === 0) {
+ return null;
+ }
+
+ const { directories } = get();
+ const dirState = directories.get(directory);
+ const previousStatus = dirState?.status ?? null;
+ if (!dirState || !previousStatus) {
+ return previousStatus;
+ }
+
+ let didChange = false;
+ const nextFiles: GitStatus['files'] = [];
+
+ for (const file of previousStatus.files) {
+ if (!normalizedPaths.has(file.path)) {
+ nextFiles.push(file);
+ continue;
+ }
+
+ const nextFile = direction === 'stage'
+ ? toStagedStatusFile(file)
+ : toUnstagedStatusFile(file);
+
+ if (nextFile !== file) {
+ didChange = true;
+ }
+
+ if (!isCleanStatusFile(nextFile)) {
+ nextFiles.push(nextFile);
+ } else {
+ didChange = true;
+ }
+ }
+
+ if (!didChange) {
+ return previousStatus;
+ }
+
+ const nextDirectories = new Map(directories);
+ nextDirectories.set(directory, {
+ ...dirState,
+ status: {
+ ...previousStatus,
+ files: nextFiles,
+ isClean: nextFiles.length === 0,
+ },
+ indexRevision: dirState.indexRevision + 1,
+ lastStatusChange: Date.now(),
+ });
+ set({ directories: nextDirectories });
+
+ return previousStatus;
+ },
+
+ restoreStatus: (directory, status) => {
+ const { directories } = get();
+ const dirState = directories.get(directory);
+ if (!dirState) {
+ return;
+ }
+
+ const nextDirectories = new Map(directories);
+ nextDirectories.set(directory, {
+ ...dirState,
+ status,
+ indexRevision: dirState.indexRevision + 1,
+ lastStatusChange: Date.now(),
+ });
+ set({ directories: nextDirectories });
+ },
+
+ bumpIndexRevision: (directory) => {
+ const { directories } = get();
+ const dirState = directories.get(directory);
+ if (!dirState) {
+ return;
+ }
+
+ const nextDirectories = new Map(directories);
+ nextDirectories.set(directory, {
+ ...dirState,
+ indexRevision: dirState.indexRevision + 1,
+ });
+ set({ directories: nextDirectories });
+ },
+
fetchBranches: async (directory, git) => {
{
const newDirectories = new Map(get().directories);
diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts
index 23c22af2..2adfeef9 100644
--- a/packages/ui/src/stores/useUIStore.ts
+++ b/packages/ui/src/stores/useUIStore.ts
@@ -25,6 +25,7 @@ type ContextPanelTab = {
dedupeKey: string;
label: string | null;
readOnly: boolean;
+ stagedDiff: boolean;
touchedAt: number;
};
@@ -34,6 +35,7 @@ type ContextPanelTabDescriptor = {
dedupeKey?: string | null;
label?: string | null;
readOnly?: boolean;
+ stagedDiff?: boolean;
};
type ContextPanelDirectoryState = {
@@ -204,6 +206,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
dedupeKey,
label: normalizeContextTabLabel(descriptor.label),
readOnly: descriptor.readOnly === true,
+ stagedDiff: descriptor.stagedDiff === true,
touchedAt: Date.now(),
};
};
@@ -243,6 +246,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
dedupeKey?: unknown;
label?: unknown;
readOnly?: unknown;
+ stagedDiff?: unknown;
touchedAt?: unknown;
};
@@ -269,6 +273,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
dedupeKey,
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
readOnly: candidate.readOnly === true,
+ stagedDiff: candidate.stagedDiff === true,
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
? candidate.touchedAt
: Date.now(),
@@ -327,6 +332,7 @@ const upsertContextPanelTab = (
targetPath: nextTab.targetPath,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
+ stagedDiff: nextTab.stagedDiff,
touchedAt: Date.now(),
}
: tab));
@@ -504,6 +510,7 @@ interface UIStore {
mainTabGuard: MainTabGuard | null;
sidebarOpenBeforeFullscreenTab: boolean | null;
pendingDiffFile: string | null;
+ pendingDiffStaged: boolean;
pendingFileNavigation: PendingFileNavigation | null;
pendingFileFocusPath: string | null;
isMobile: boolean;
@@ -611,7 +618,7 @@ interface UIStore {
setRightSidebarWidth: (width: number) => void;
setRightSidebarTab: (tab: RightSidebarTab) => void;
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
- openContextDiff: (directory: string, filePath: string) => void;
+ openContextDiff: (directory: string, filePath: string, staged?: boolean) => void;
openContextFile: (directory: string, filePath: string) => void;
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
openContextOverview: (directory: string) => void;
@@ -635,10 +642,10 @@ interface UIStore {
setSessionDropdownOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
setMainTabGuard: (guard: MainTabGuard | null) => void;
- setPendingDiffFile: (filePath: string | null) => void;
+ setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
setPendingFileFocusPath: (path: string | null) => void;
- navigateToDiff: (filePath: string) => void;
+ navigateToDiff: (filePath: string, staged?: boolean) => void;
consumePendingDiffFile: () => string | null;
setIsMobile: (isMobile: boolean) => void;
toggleCommandPalette: () => void;
@@ -772,6 +779,7 @@ export const useUIStore = create()(
mainTabGuard: null,
sidebarOpenBeforeFullscreenTab: null,
pendingDiffFile: null,
+ pendingDiffStaged: false,
pendingFileNavigation: null,
pendingFileFocusPath: null,
isMobile: false,
@@ -975,15 +983,19 @@ export const useUIStore = create()(
});
},
- openContextDiff: (directory, filePath) => {
+ openContextDiff: (directory, filePath, staged = false) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedFilePath = (filePath || '').trim();
if (!normalizedDirectory || !normalizedFilePath) {
return;
}
- get().openContextPanelTab(normalizedDirectory, { mode: 'diff', targetPath: normalizedFilePath });
- get().setPendingDiffFile(normalizedFilePath);
+ get().openContextPanelTab(normalizedDirectory, {
+ mode: 'diff',
+ targetPath: normalizedFilePath,
+ dedupeKey: staged ? 'staged' : null,
+ stagedDiff: staged,
+ });
},
openContextFile: (directory, filePath) => {
@@ -1334,8 +1346,8 @@ export const useUIStore = create()(
set({ activeMainTab: tab });
},
- setPendingDiffFile: (filePath) => {
- set({ pendingDiffFile: filePath });
+ setPendingDiffFile: (filePath, staged = false) => {
+ set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
},
setPendingFileNavigation: (navigation) => {
@@ -1346,18 +1358,18 @@ export const useUIStore = create()(
set({ pendingFileFocusPath: path });
},
- navigateToDiff: (filePath) => {
+ navigateToDiff: (filePath, staged = false) => {
const guard = get().mainTabGuard;
if (guard && !guard('diff')) {
return;
}
- set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
+ set({ pendingDiffFile: filePath, pendingDiffStaged: staged, activeMainTab: 'diff' });
},
consumePendingDiffFile: () => {
const { pendingDiffFile } = get();
if (pendingDiffFile) {
- set({ pendingDiffFile: null });
+ set({ pendingDiffFile: null, pendingDiffStaged: false });
}
return pendingDiffFile;
},
diff --git a/packages/vscode/src/bridge-git-runtime.test.js b/packages/vscode/src/bridge-git-runtime.test.js
new file mode 100644
index 00000000..ef523d55
--- /dev/null
+++ b/packages/vscode/src/bridge-git-runtime.test.js
@@ -0,0 +1,72 @@
+import { beforeEach, describe, expect, it, mock } from 'bun:test';
+
+const gitService = {
+ stageGitFiles: mock(),
+ unstageGitFiles: mock(),
+};
+
+mock.module('./gitService', () => gitService);
+
+const { handleStandardGitBridgeMessage } = await import('./bridge-git-runtime');
+
+describe('bridge git runtime index mutations', () => {
+ beforeEach(() => {
+ gitService.stageGitFiles.mockReset();
+ gitService.unstageGitFiles.mockReset();
+ });
+
+ it('accepts legacy stage path payloads', async () => {
+ const response = await handleStandardGitBridgeMessage({
+ id: '1',
+ type: 'api:git/stage',
+ payload: { directory: '/repo', path: 'a.ts' },
+ });
+
+ expect(response).toEqual({ id: '1', type: 'api:git/stage', success: true, data: { success: true } });
+ expect(gitService.stageGitFiles).toHaveBeenCalledWith('/repo', ['a.ts']);
+ });
+
+ it('accepts bulk stage paths payloads', async () => {
+ const response = await handleStandardGitBridgeMessage({
+ id: '1',
+ type: 'api:git/stage',
+ payload: { directory: '/repo', paths: ['a.ts', 'b.ts'] },
+ });
+
+ expect(response?.success).toBe(true);
+ expect(gitService.stageGitFiles).toHaveBeenCalledWith('/repo', ['a.ts', 'b.ts']);
+ });
+
+ it('accepts legacy unstage path payloads', async () => {
+ const response = await handleStandardGitBridgeMessage({
+ id: '1',
+ type: 'api:git/unstage',
+ payload: { directory: '/repo', path: 'a.ts' },
+ });
+
+ expect(response).toEqual({ id: '1', type: 'api:git/unstage', success: true, data: { success: true } });
+ expect(gitService.unstageGitFiles).toHaveBeenCalledWith('/repo', ['a.ts']);
+ });
+
+ it('accepts bulk unstage paths payloads', async () => {
+ const response = await handleStandardGitBridgeMessage({
+ id: '1',
+ type: 'api:git/unstage',
+ payload: { directory: '/repo', paths: ['a.ts', 'b.ts'] },
+ });
+
+ expect(response?.success).toBe(true);
+ expect(gitService.unstageGitFiles).toHaveBeenCalledWith('/repo', ['a.ts', 'b.ts']);
+ });
+
+ it('rejects invalid path payloads', async () => {
+ const response = await handleStandardGitBridgeMessage({
+ id: '1',
+ type: 'api:git/stage',
+ payload: { directory: '/repo', paths: [' ', null] },
+ });
+
+ expect(response?.success).toBe(false);
+ expect(gitService.stageGitFiles).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts
index 76798906..6e93470d 100644
--- a/packages/vscode/src/bridge-git-runtime.ts
+++ b/packages/vscode/src/bridge-git-runtime.ts
@@ -217,25 +217,48 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
}
case 'api:git/revert': {
- const { directory, path: filePath } = (payload || {}) as { directory?: string; path?: string };
+ const { directory, path: filePath, scope } = (payload || {}) as { directory?: string; path?: string; scope?: 'all' | 'working' };
if (!directory || !filePath) {
return { id, type, success: false, error: 'Directory and path are required' };
}
- await gitService.revertGitFile(directory, filePath);
+ await gitService.revertGitFile(directory, filePath, { scope });
+ return { id, type, success: true, data: { success: true } };
+ }
+
+ case 'api:git/stage': {
+ const { directory, path: filePath, paths } = (payload || {}) as { directory?: string; path?: string; paths?: string[] };
+ const filePaths = (Array.isArray(paths) ? paths : [filePath])
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
+ if (!directory || filePaths.length === 0) {
+ return { id, type, success: false, error: 'Directory and path are required' };
+ }
+ await gitService.stageGitFiles(directory, filePaths);
+ return { id, type, success: true, data: { success: true } };
+ }
+
+ case 'api:git/unstage': {
+ const { directory, path: filePath, paths } = (payload || {}) as { directory?: string; path?: string; paths?: string[] };
+ const filePaths = (Array.isArray(paths) ? paths : [filePath])
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
+ if (!directory || filePaths.length === 0) {
+ return { id, type, success: false, error: 'Directory and path are required' };
+ }
+ await gitService.unstageGitFiles(directory, filePaths);
return { id, type, success: true, data: { success: true } };
}
case 'api:git/commit': {
- const { directory, message, addAll, files } = (payload || {}) as {
+ const { directory, message, addAll, files, stageFiles } = (payload || {}) as {
directory?: string;
message?: string;
addAll?: boolean;
files?: string[];
+ stageFiles?: string[];
};
if (!directory || !message) {
return { id, type, success: false, error: 'Directory and message are required' };
}
- const result = await gitService.createGitCommit(directory, message, { addAll, files });
+ const result = await gitService.createGitCommit(directory, message, { addAll, files, stageFiles });
return { id, type, success: true, data: result };
}
diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts
index 5d8b369c..7eb76f15 100644
--- a/packages/vscode/src/gitService.ts
+++ b/packages/vscode/src/gitService.ts
@@ -396,6 +396,10 @@ function mapStatus(status: Status): string {
return statusMap[status] || ' ';
}
+function getRepositoryRelativePath(repo: Repository, uri: vscode.Uri): string {
+ return path.relative(repo.rootUri.fsPath, uri.fsPath).replace(/\\/g, '/');
+}
+
/**
* Get git status for a directory
*/
@@ -417,7 +421,7 @@ export async function getGitStatus(directory: string, options?: GitStatusOptions
// Process index changes (staged)
for (const change of state.indexChanges) {
- const relativePath = vscode.workspace.asRelativePath(change.uri, false);
+ const relativePath = getRepositoryRelativePath(repo, change.uri);
files.push({
path: relativePath,
index: mapStatus(change.status),
@@ -427,7 +431,7 @@ export async function getGitStatus(directory: string, options?: GitStatusOptions
// Process working tree changes (unstaged)
for (const change of state.workingTreeChanges) {
- const relativePath = vscode.workspace.asRelativePath(change.uri, false);
+ const relativePath = getRepositoryRelativePath(repo, change.uri);
const existing = files.find(f => f.path === relativePath);
if (existing) {
existing.working_dir = mapStatus(change.status);
@@ -2085,10 +2089,15 @@ export async function getGitFileDiff(
}
}
- // Read the current file content
- const fileUri = vscode.Uri.file(path.join(directory, filePath));
- const modifiedBytes = await vscode.workspace.fs.readFile(fileUri);
- const modified = Buffer.from(modifiedBytes).toString('utf8');
+ let modified: string;
+ if (staged) {
+ const stagedResult = await execGit(['show', `:${filePath}`], directory);
+ modified = stagedResult.exitCode === 0 ? stagedResult.stdout : '';
+ } else {
+ const fileUri = vscode.Uri.file(path.join(directory, filePath));
+ const modifiedBytes = await vscode.workspace.fs.readFile(fileUri);
+ modified = Buffer.from(modifiedBytes).toString('utf8');
+ }
return { original, modified, path: filePath };
} catch (error) {
@@ -2103,20 +2112,101 @@ export async function getGitFileDiff(
/**
* Revert a file to its last committed state
*/
-export async function revertGitFile(directory: string, filePath: string): Promise {
- const repo = await getRepository(directory);
-
- if (repo) {
- try {
- await repo.revert([filePath]);
- return;
- } catch (error) {
- console.error('[GitService] Failed to revert via API:', error);
+export async function revertGitFile(
+ directory: string,
+ filePath: string,
+ options: { scope?: 'all' | 'working' } = {},
+): Promise {
+ const scope = options.scope === 'working' ? 'working' : 'all';
+ const tracked = await execGit(['ls-files', '--error-unmatch', '--', filePath], directory);
+ if (tracked.exitCode !== 0) {
+ const clean = await execGit(['clean', '-f', '-d', '--', filePath], directory);
+ if (clean.exitCode !== 0) {
+ const root = path.resolve(directory);
+ const target = path.resolve(directory, filePath);
+ if (target !== root && !target.startsWith(root + path.sep)) {
+ throw new Error(`Path is outside repository: ${filePath}`);
+ }
+ await fs.promises.rm(target, { recursive: true, force: true });
+ }
+ return;
+ }
+
+ if (scope === 'all') {
+ const unstage = await execGit(['restore', '--staged', '--', filePath], directory);
+ if (unstage.exitCode !== 0) {
+ await execGit(['reset', 'HEAD', '--', filePath], directory);
}
}
- // Fallback to raw git
- await execGit(['checkout', '--', filePath], directory);
+ const restore = await execGit(['restore', '--', filePath], directory);
+ if (restore.exitCode === 0) {
+ return;
+ }
+
+ const fallback = await execGit(['checkout', '--', filePath], directory);
+ if (fallback.exitCode !== 0) {
+ throw new Error(fallback.stderr || restore.stderr || 'Failed to revert git file');
+ }
+}
+
+export async function stageGitFile(directory: string, filePath: string): Promise {
+ await stageGitFiles(directory, [filePath]);
+}
+
+export async function stageGitFiles(directory: string, filePaths: string[]): Promise {
+ const paths = filePaths.map((path) => path.trim()).filter(Boolean);
+
+ if (paths.length === 0) {
+ throw new Error('path is required');
+ }
+ const result = await execGit(['add', '--', ...paths], directory);
+ if (result.exitCode === 0) {
+ return;
+ }
+
+ const isPathspecError =
+ /pathspec/.test(result.stderr) && /did not match any files/.test(result.stderr);
+ if (!isPathspecError) {
+ throw new Error(result.stderr || 'Failed to stage git file');
+ }
+
+ // During rapid stage/unstage toggling the optimistic UI can request staging a
+ // path that a prior queued mutation already staged (most visibly a deletion,
+ // whose file is gone from the working tree). `git add` aborts the whole batch on
+ // a single unmatched pathspec, so retry per-path and skip the ones already in
+ // their target state rather than failing the entire "stage all".
+ for (const path of paths) {
+ const perPath = await execGit(['add', '--', path], directory);
+ if (perPath.exitCode === 0) {
+ continue;
+ }
+ const perPathIsPathspecError =
+ /pathspec/.test(perPath.stderr) && /did not match any files/.test(perPath.stderr);
+ if (!perPathIsPathspecError) {
+ throw new Error(perPath.stderr || 'Failed to stage git file');
+ }
+ }
+}
+
+export async function unstageGitFile(directory: string, filePath: string): Promise {
+ await unstageGitFiles(directory, [filePath]);
+}
+
+export async function unstageGitFiles(directory: string, filePaths: string[]): Promise {
+ const paths = filePaths.map((path) => path.trim()).filter(Boolean);
+
+ if (paths.length === 0) {
+ throw new Error('path is required');
+ }
+ const result = await execGit(['restore', '--staged', '--', ...paths], directory);
+ if (result.exitCode === 0) {
+ return;
+ }
+ const fallback = await execGit(['reset', 'HEAD', '--', ...paths], directory);
+ if (fallback.exitCode !== 0) {
+ throw new Error(fallback.stderr || result.stderr || 'Failed to unstage git file');
+ }
}
// ============== Commit Operations ==============
@@ -2138,8 +2228,49 @@ export interface GitCommitResult {
export async function createGitCommit(
directory: string,
message: string,
- options?: { addAll?: boolean; files?: string[] }
+ options?: { addAll?: boolean; files?: string[]; stageFiles?: string[] }
): Promise {
+ if (options?.files?.length && options.stageFiles) {
+ const selectedFiles = new Set(options.files);
+ const stagedResult = await execGit(['diff', '--cached', '--name-only'], directory);
+ const temporarilyUnstagedFiles = stagedResult.stdout
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((filePath) => filePath && !selectedFiles.has(filePath));
+
+ try {
+ if (temporarilyUnstagedFiles.length > 0) {
+ await execGit(['restore', '--staged', '--', ...temporarilyUnstagedFiles], directory);
+ }
+ if (options.stageFiles.length > 0) {
+ await execGit(['add', '--', ...options.stageFiles], directory);
+ }
+
+ const result = await execGit(['commit', '-m', message], directory);
+ if (result.exitCode !== 0) {
+ return {
+ success: false,
+ commit: '',
+ branch: '',
+ summary: { changes: 0, insertions: 0, deletions: 0 },
+ };
+ }
+
+ const hashResult = await execGit(['rev-parse', 'HEAD'], directory);
+ const branchResult = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], directory);
+ return {
+ success: true,
+ commit: hashResult.stdout.trim(),
+ branch: branchResult.stdout.trim(),
+ summary: { changes: 0, insertions: 0, deletions: 0 },
+ };
+ } finally {
+ if (temporarilyUnstagedFiles.length > 0) {
+ await execGit(['add', '--', ...temporarilyUnstagedFiles], directory);
+ }
+ }
+ }
+
const repo = await getRepository(directory);
if (repo) {
@@ -2147,7 +2278,10 @@ export async function createGitCommit(
if (options?.addAll) {
await repo.add(['.']);
} else if (options?.files?.length) {
- await repo.add(options.files);
+ const filesToStage = options.stageFiles ?? options.files;
+ if (filesToStage.length > 0) {
+ await repo.add(filesToStage);
+ }
}
await repo.commit(message);
@@ -2168,7 +2302,10 @@ export async function createGitCommit(
if (options?.addAll) {
await execGit(['add', '-A'], directory);
} else if (options?.files?.length) {
- await execGit(['add', ...options.files], directory);
+ const filesToStage = options.stageFiles ?? options.files;
+ if (filesToStage.length > 0) {
+ await execGit(['add', ...filesToStage], directory);
+ }
}
const result = await execGit(['commit', '-m', message], directory);
@@ -2475,6 +2612,13 @@ export async function stashGitChanges(directory: string, options: { message?: st
export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const ref = options.ref || 'stash@{0}';
+ // Prefer --index so the staged/unstaged split captured in the stash is restored
+ // faithfully. Fall back to a plain apply when the index can't be reinstated
+ // cleanly (e.g. conflicts), which is the prior behavior.
+ const withIndex = await execGit(['stash', 'apply', '--index', ref], directory);
+ if (withIndex.exitCode === 0) {
+ return { success: true, ref };
+ }
const result = await execGit(['stash', 'apply', ref], directory);
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to apply stash');
return { success: true, ref };
diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts
index c94412d0..cc049f3c 100644
--- a/packages/vscode/webview/api/git.ts
+++ b/packages/vscode/webview/api/git.ts
@@ -63,8 +63,24 @@ export const createVSCodeGitAPI = (): GitAPI => ({
});
},
- revertGitFile: async (directory: string, filePath: string): Promise => {
- await sendBridgeMessage('api:git/revert', { directory, path: filePath });
+ revertGitFile: async (directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise => {
+ await sendBridgeMessage('api:git/revert', { directory, path: filePath, scope: options?.scope });
+ },
+
+ stageGitFile: async (directory: string, filePath: string): Promise => {
+ await sendBridgeMessage('api:git/stage', { directory, path: filePath });
+ },
+
+ stageGitFiles: async (directory: string, filePaths: string[]): Promise => {
+ await sendBridgeMessage('api:git/stage', { directory, paths: filePaths });
+ },
+
+ unstageGitFile: async (directory: string, filePath: string): Promise => {
+ await sendBridgeMessage('api:git/unstage', { directory, path: filePath });
+ },
+
+ unstageGitFiles: async (directory: string, filePaths: string[]): Promise => {
+ await sendBridgeMessage('api:git/unstage', { directory, paths: filePaths });
},
isLinkedWorktree: async (directory: string): Promise => {
@@ -182,6 +198,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({
message,
addAll: options?.addAll,
files: options?.files,
+ stageFiles: options?.stageFiles,
});
},
diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md
index d05ce9ef..9886d14f 100644
--- a/packages/web/server/lib/git/DOCUMENTATION.md
+++ b/packages/web/server/lib/git/DOCUMENTATION.md
@@ -30,7 +30,9 @@ The following functions are exported and used by the web server:
- `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs.
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs).
- `collectDiffs(directory, files)`: Collect diff output for multiple files.
-- `revertFile(directory, filePath)`: Revert a file to HEAD state.
+- `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes.
+- `stageFile(directory, filePath)`: Add one file path to the index.
+- `unstageFile(directory, filePath)`: Remove one file path from the index while preserving working-tree content.
### Branch Operations
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
@@ -48,7 +50,7 @@ The following functions are exported and used by the web server:
- `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
### Commit and Remote Operations
-- `commit(directory, message, options)`: Create a commit (supports addAll or specific files).
+- `commit(directory, message, options)`: Create a commit from the current index. `options.stageFiles` may be provided with `options.files` by older callers to stage only selected unstaged rows before committing, but the shared Git panel now stages/unstages explicitly before commit.
- `pull(directory, options)`: Pull changes from remote.
- `push(directory, options)`: Push changes to remote (auto-sets upstream if needed).
- `fetch(directory, options)`: Fetch changes from remote.
@@ -105,6 +107,11 @@ The following functions are internal helpers used by exported functions:
- `mergeInProgress`: Object with `{ head, message }` if merge in progress.
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
+### Staged and unstaged change handling
+- `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files.
+- A file with both staged and unstaged changes can appear in both UI sections. Staged rows request diffs with `staged: true`; unstaged rows request normal working-tree diffs.
+- The shared Git panel exposes explicit staging actions. Unstaged rows use `stageFile`, staged rows use `unstageFile`, and commits operate on the current staged index.
+- `stageFiles` remains supported for callers that need to stage a selected unstaged subset as part of commit. In that mode the server temporarily unstages unrelated index entries, stages `stageFiles`, commits from the index, then restores temporarily unstaged entries.
### Worktree Create/Remove Response
- `head`: HEAD commit SHA.
- `name`: Worktree name.
diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js
index 1ea2dd4d..17cb2594 100644
--- a/packages/web/server/lib/git/routes.js
+++ b/packages/web/server/lib/git/routes.js
@@ -291,12 +291,12 @@ export function registerGitRoutes(app) {
return res.status(400).json({ error: 'directory parameter is required' });
}
- const { path } = req.body || {};
+ const { path, scope } = req.body || {};
if (!path || typeof path !== 'string') {
return res.status(400).json({ error: 'path parameter is required' });
}
- await revertFile(directory, path);
+ await revertFile(directory, path, { scope });
res.json({ success: true });
} catch (error) {
console.error('Failed to revert git file:', error);
@@ -304,6 +304,50 @@ export function registerGitRoutes(app) {
}
});
+ app.post('/api/git/stage', async (req, res) => {
+ const { stageFiles } = await getGitLibraries();
+ try {
+ const directory = req.query.directory;
+ if (!directory) {
+ return res.status(400).json({ error: 'directory parameter is required' });
+ }
+
+ const { path, paths } = req.body || {};
+ const filePaths = Array.isArray(paths) ? paths : [path];
+ if (!filePaths.some((value) => typeof value === 'string' && value.trim())) {
+ return res.status(400).json({ error: 'path parameter is required' });
+ }
+
+ await stageFiles(directory, filePaths);
+ res.json({ success: true });
+ } catch (error) {
+ console.error('Failed to stage git file:', error);
+ res.status(500).json({ error: error.message || 'Failed to stage git file' });
+ }
+ });
+
+ app.post('/api/git/unstage', async (req, res) => {
+ const { unstageFiles } = await getGitLibraries();
+ try {
+ const directory = req.query.directory;
+ if (!directory) {
+ return res.status(400).json({ error: 'directory parameter is required' });
+ }
+
+ const { path, paths } = req.body || {};
+ const filePaths = Array.isArray(paths) ? paths : [path];
+ if (!filePaths.some((value) => typeof value === 'string' && value.trim())) {
+ return res.status(400).json({ error: 'path parameter is required' });
+ }
+
+ await unstageFiles(directory, filePaths);
+ res.json({ success: true });
+ } catch (error) {
+ console.error('Failed to unstage git file:', error);
+ res.status(500).json({ error: error.message || 'Failed to unstage git file' });
+ }
+ });
+
app.post('/api/git/pull', async (req, res) => {
const { pull } = await getGitLibraries();
try {
@@ -581,7 +625,7 @@ export function registerGitRoutes(app) {
return res.status(400).json({ error: 'directory parameter is required' });
}
- const { message, addAll, files } = req.body;
+ const { message, addAll, files, stageFiles } = req.body;
if (!message) {
return res.status(400).json({ error: 'message is required' });
}
@@ -589,6 +633,7 @@ export function registerGitRoutes(app) {
const result = await commit(directory, message, {
addAll,
files,
+ stageFiles,
});
res.json(result);
} catch (error) {
diff --git a/packages/web/server/lib/git/routes.test.js b/packages/web/server/lib/git/routes.test.js
new file mode 100644
index 00000000..ec028d48
--- /dev/null
+++ b/packages/web/server/lib/git/routes.test.js
@@ -0,0 +1,137 @@
+import { beforeEach, describe, expect, it, mock } from 'bun:test';
+
+const gitLibraries = {
+ stageFiles: mock(),
+ unstageFiles: mock(),
+};
+
+mock.module('./index.js', () => ({
+ stageFiles: gitLibraries.stageFiles,
+ unstageFiles: gitLibraries.unstageFiles,
+}));
+
+const { registerGitRoutes } = await import('./routes.js');
+
+const createRouteRegistry = () => {
+ const routes = new Map();
+
+ return {
+ app: {
+ get(routePath, handler) {
+ routes.set(`GET ${routePath}`, handler);
+ },
+ post(routePath, handler) {
+ routes.set(`POST ${routePath}`, handler);
+ },
+ put(routePath, handler) {
+ routes.set(`PUT ${routePath}`, handler);
+ },
+ delete(routePath, handler) {
+ routes.set(`DELETE ${routePath}`, handler);
+ },
+ },
+ getRoute(method, routePath) {
+ return routes.get(`${method} ${routePath}`);
+ },
+ };
+};
+
+const createMockResponse = () => {
+ let statusCode = 200;
+ let body = null;
+
+ return {
+ status(code) {
+ statusCode = code;
+ return this;
+ },
+ json(payload) {
+ body = payload;
+ return this;
+ },
+ get statusCode() {
+ return statusCode;
+ },
+ get body() {
+ return body;
+ },
+ };
+};
+
+describe('git routes index mutations', () => {
+ beforeEach(() => {
+ gitLibraries.stageFiles.mockReset();
+ gitLibraries.unstageFiles.mockReset();
+ });
+
+ it('accepts legacy stage path payloads', async () => {
+ const { app, getRoute } = createRouteRegistry();
+ registerGitRoutes(app);
+ const response = createMockResponse();
+
+ await getRoute('POST', '/api/git/stage')(
+ { query: { directory: '/repo' }, body: { path: 'a.ts' } },
+ response,
+ );
+
+ expect(response.statusCode).toBe(200);
+ expect(gitLibraries.stageFiles).toHaveBeenCalledWith('/repo', ['a.ts']);
+ });
+
+ it('accepts bulk stage paths payloads', async () => {
+ const { app, getRoute } = createRouteRegistry();
+ registerGitRoutes(app);
+ const response = createMockResponse();
+
+ await getRoute('POST', '/api/git/stage')(
+ { query: { directory: '/repo' }, body: { paths: ['a.ts', 'b.ts'] } },
+ response,
+ );
+
+ expect(response.statusCode).toBe(200);
+ expect(gitLibraries.stageFiles).toHaveBeenCalledWith('/repo', ['a.ts', 'b.ts']);
+ });
+
+ it('accepts legacy unstage path payloads', async () => {
+ const { app, getRoute } = createRouteRegistry();
+ registerGitRoutes(app);
+ const response = createMockResponse();
+
+ await getRoute('POST', '/api/git/unstage')(
+ { query: { directory: '/repo' }, body: { path: 'a.ts' } },
+ response,
+ );
+
+ expect(response.statusCode).toBe(200);
+ expect(gitLibraries.unstageFiles).toHaveBeenCalledWith('/repo', ['a.ts']);
+ });
+
+ it('accepts bulk unstage paths payloads', async () => {
+ const { app, getRoute } = createRouteRegistry();
+ registerGitRoutes(app);
+ const response = createMockResponse();
+
+ await getRoute('POST', '/api/git/unstage')(
+ { query: { directory: '/repo' }, body: { paths: ['a.ts', 'b.ts'] } },
+ response,
+ );
+
+ expect(response.statusCode).toBe(200);
+ expect(gitLibraries.unstageFiles).toHaveBeenCalledWith('/repo', ['a.ts', 'b.ts']);
+ });
+
+ it('rejects invalid path payloads before calling git', async () => {
+ const { app, getRoute } = createRouteRegistry();
+ registerGitRoutes(app);
+ const response = createMockResponse();
+
+ await getRoute('POST', '/api/git/stage')(
+ { query: { directory: '/repo' }, body: { paths: [' ', null] } },
+ response,
+ );
+
+ expect(response.statusCode).toBe(400);
+ expect(response.body).toEqual({ error: 'path parameter is required' });
+ expect(gitLibraries.stageFiles).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js
index 4fadc1a3..fc25a96b 100644
--- a/packages/web/server/lib/git/service.js
+++ b/packages/web/server/lib/git/service.js
@@ -12,6 +12,7 @@ const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
let resolvedGitBinary = null;
const worktreeBootstrapState = new Map();
+const gitIndexMutationQueues = new Map();
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
const WORKTREE_BOOTSTRAP_READY = 'ready';
@@ -307,6 +308,60 @@ const normalizeDirectoryPath = (value) => {
return trimmed;
};
+const getGitIndexMutationQueueKey = (directory) => {
+ const normalized = normalizeDirectoryPath(directory);
+ if (!normalized) {
+ return '';
+ }
+ return path.resolve(normalized);
+};
+
+const withGitIndexMutationQueue = async (directory, task) => {
+ let key = getGitIndexMutationQueueKey(directory);
+ try {
+ const directoryPath = normalizeDirectoryPath(directory);
+ if (directoryPath) {
+ const git = await createGit(directoryPath);
+ key = await resolveGitRepositoryRoot(directoryPath, git);
+ }
+ } catch {
+ // Fall back to the normalized directory key when the repo root is unavailable.
+ }
+ if (!key) {
+ return task();
+ }
+
+ const previous = gitIndexMutationQueues.get(key) || Promise.resolve();
+ const current = previous.catch(() => {}).then(task);
+ const tail = current.catch(() => {});
+ gitIndexMutationQueues.set(key, tail);
+
+ try {
+ return await current;
+ } finally {
+ if (gitIndexMutationQueues.get(key) === tail) {
+ gitIndexMutationQueues.delete(key);
+ }
+ }
+};
+
+const normalizeFilePathList = (paths) => Array.from(new Set(
+ (Array.isArray(paths) ? paths : [paths])
+ .map((value) => String(value || '').trim())
+ .filter(Boolean)
+));
+
+const validateRepositoryFilePaths = (directoryPath, filePaths) => {
+ const repoRoot = path.resolve(directoryPath);
+
+ for (const filePath of filePaths) {
+ const absoluteTarget = path.resolve(repoRoot, filePath);
+ if (!absoluteTarget.startsWith(repoRoot + path.sep) && absoluteTarget !== repoRoot) {
+ throw new Error(`Path is outside repository: ${filePath}`);
+ }
+ }
+};
+
const toGitPath = (value) => value.replace(/\\/g, '/');
const isInsideOrSameDirectory = (root, target) => {
@@ -1811,14 +1866,30 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
let modified = '';
try {
- const stat = await fsp.stat(absolutePath);
- if (stat.isFile()) {
+ if (staged) {
if (isImage) {
- // For images, read as binary and convert to data URL
- const buffer = await fsp.readFile(absolutePath);
- modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
+ const { stdout } = await execFileAsync(getGitBinary(), ['show', `:${repoPath}`], {
+ cwd: repoRoot,
+ encoding: 'buffer',
+ windowsHide: true,
+ maxBuffer: 50 * 1024 * 1024,
+ });
+ if (stdout && stdout.length > 0) {
+ modified = `data:${mimeType};base64,${stdout.toString('base64')}`;
+ }
} else {
- modified = await fsp.readFile(absolutePath, 'utf8');
+ modified = await git.show([`:${repoPath}`]);
+ }
+ } else {
+ const stat = await fsp.stat(absolutePath);
+ if (stat.isFile()) {
+ if (isImage) {
+ // For images, read as binary and convert to data URL
+ const buffer = await fsp.readFile(absolutePath);
+ modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
+ } else {
+ modified = await fsp.readFile(absolutePath, 'utf8');
+ }
}
}
} catch (error) {
@@ -1838,52 +1909,57 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
};
}
-export async function revertFile(directory, filePath) {
- const directoryPath = normalizeDirectoryPath(directory);
- const directoryGit = await createGit(directoryPath);
- const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
- const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
- const git = await createGit(repoRoot);
+export async function revertFile(directory, filePath, options = {}) {
+ return withGitIndexMutationQueue(directory, async () => {
+ const scope = options?.scope === 'working' ? 'working' : 'all';
+ const directoryPath = normalizeDirectoryPath(directory);
+ const directoryGit = await createGit(directoryPath);
+ const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
+ const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
+ const git = await createGit(repoRoot);
- const isTracked = await git
- .raw(['ls-files', '--error-unmatch', '--', repoPath])
- .then(() => true)
- .catch(() => false);
+ const isTracked = await git
+ .raw(['ls-files', '--error-unmatch', '--', repoPath])
+ .then(() => true)
+ .catch(() => false);
- if (!isTracked) {
- try {
- await git.raw(['clean', '-f', '-d', '--', repoPath]);
- return;
- } catch (cleanError) {
+ if (!isTracked) {
try {
- await fsp.rm(absolutePath, { recursive: true, force: true });
+ await git.raw(['clean', '-f', '-d', '--', repoPath]);
return;
- } catch (fsError) {
- if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
+ } catch (cleanError) {
+ try {
+ await fsp.rm(absolutePath, { recursive: true, force: true });
return;
+ } catch (fsError) {
+ if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
+ return;
+ }
+ console.error('Failed to remove untracked file during revert:', fsError);
+ throw fsError;
}
- console.error('Failed to remove untracked file during revert:', fsError);
- throw fsError;
}
}
- }
- try {
- await git.raw(['restore', '--staged', '--', repoPath]);
- } catch (error) {
- await git.raw(['reset', 'HEAD', '--', repoPath]).catch(() => {});
- }
-
- try {
- await git.raw(['restore', '--', repoPath]);
- } catch (error) {
- try {
- await git.raw(['checkout', '--', repoPath]);
- } catch (fallbackError) {
- console.error('Failed to revert git file:', fallbackError);
- throw fallbackError;
+ if (scope === 'all') {
+ try {
+ await git.raw(['restore', '--staged', '--', repoPath]);
+ } catch (error) {
+ await git.raw(['reset', 'HEAD', '--', repoPath]).catch(() => {});
+ }
}
- }
+
+ try {
+ await git.raw(['restore', '--', repoPath]);
+ } catch (error) {
+ try {
+ await git.raw(['checkout', '--', repoPath]);
+ } catch (fallbackError) {
+ console.error('Failed to revert git file:', fallbackError);
+ throw fallbackError;
+ }
+ }
+ });
}
export async function collectDiffs(directory, files = []) {
@@ -1981,7 +2057,12 @@ export async function stashPush(directory, options = {}) {
export async function stashApply(directory, options = {}) {
const { git } = await createRepositoryGitContext(directory);
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
- await git.raw(['stash', 'apply', ref]);
+ // Prefer --index so the staged/unstaged split captured in the stash is restored
+ // faithfully. Fall back to a plain apply when the index can't be reinstated
+ // cleanly (e.g. conflicts), which is the prior behavior.
+ await git.raw(['stash', 'apply', '--index', ref]).catch(async () => {
+ await git.raw(['stash', 'apply', ref]);
+ });
return { success: true, ref };
}
@@ -2172,77 +2253,197 @@ export async function fetch(directory, options = {}) {
}
}
-export async function commit(directory, message, options = {}) {
- const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
+export async function stageFile(directory, filePath) {
+ await stageFiles(directory, [filePath]);
+}
- try {
- const requestedFiles = Array.isArray(options.files)
- ? options.files
- .map((value) => String(value || '').trim())
- .filter(Boolean)
- : [];
- let filesToCommit = [];
+export async function stageFiles(directory, paths) {
+ if (!directory) {
+ throw new Error('directory and path are required for stageFile');
+ }
- if (options.addAll) {
- await git.add('.');
- } else if (requestedFiles.length > 0) {
- filesToCommit = Array.from(new Set(await Promise.all(requestedFiles.map(async (filePath) => {
- const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
- return fileContext.repoPath;
- }))));
+ const filePaths = normalizeFilePathList(paths);
+ if (filePaths.length === 0) {
+ throw new Error('directory and path are required for stageFile');
+ }
+ validateRepositoryFilePaths(normalizeDirectoryPath(directory), filePaths);
- const status = await git.status();
- const fileStatusByPath = new Map(status.files.map((file) => [file.path, file]));
- filesToCommit = filesToCommit.filter((filePath) => fileStatusByPath.has(filePath));
-
- if (filesToCommit.length === 0) {
- throw new Error('No selected files are available to commit. Refresh git status and try again.');
- }
-
- const filesNeedingAdd = filesToCommit.filter((filePath) => {
- const fileStatus = fileStatusByPath.get(filePath);
- if (!fileStatus) {
- return false;
- }
-
- const alreadyFullyStaged = fileStatus.index !== ' ' && fileStatus.working_dir === ' ';
- return !alreadyFullyStaged;
- });
-
- if (filesNeedingAdd.length > 0) {
- await git.add(filesNeedingAdd);
- }
- }
-
- const commitArgs =
- !options.addAll && filesToCommit.length > 0
- ? filesToCommit
- : undefined;
-
- let result;
- try {
- result = await git.commit(message, commitArgs);
- } catch (error) {
+ await withGitIndexMutationQueue(directory, async () => {
+ const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
+ const repoPaths = Array.from(new Set(await Promise.all(filePaths.map(async (filePath) => {
+ const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
+ return fileContext.repoPath;
+ }))));
+ validateRepositoryFilePaths(repoRoot, repoPaths);
+ await git.raw(['add', '--', ...repoPaths]).catch(async (error) => {
const gitErrorText = parseGitErrorText(error);
const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
- if (!isPathspecError || !commitArgs || commitArgs.length === 0) {
+ if (!isPathspecError) {
throw error;
}
- // Fallback for deleted/stale selections: commit currently staged changes.
- result = await git.commit(message);
- }
+ // During rapid stage/unstage toggling the optimistic UI can request staging a
+ // path that a prior queued mutation already staged (most visibly a deletion,
+ // whose file is gone from the working tree). `git add` aborts the whole batch
+ // on a single unmatched pathspec, so retry per-path and skip the ones already
+ // in their target state rather than failing the entire "stage all".
+ for (const repoPath of repoPaths) {
+ await git.raw(['add', '--', repoPath]).catch((perPathError) => {
+ const perPathText = parseGitErrorText(perPathError);
+ const perPathIsPathspecError =
+ perPathText.includes('pathspec') && perPathText.includes('did not match any files');
+ if (!perPathIsPathspecError) {
+ throw perPathError;
+ }
+ });
+ }
+ });
+ });
+}
- return {
- success: true,
- commit: result.commit,
- branch: result.branch,
- summary: result.summary
- };
- } catch (error) {
- console.error('Failed to commit:', error);
- throw error;
+export async function unstageFile(directory, filePath) {
+ await unstageFiles(directory, [filePath]);
+}
+
+export async function unstageFiles(directory, paths) {
+ if (!directory) {
+ throw new Error('directory and path are required for unstageFile');
}
+
+ const filePaths = normalizeFilePathList(paths);
+ if (filePaths.length === 0) {
+ throw new Error('directory and path are required for unstageFile');
+ }
+ validateRepositoryFilePaths(normalizeDirectoryPath(directory), filePaths);
+
+ await withGitIndexMutationQueue(directory, async () => {
+ const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
+ const repoPaths = Array.from(new Set(await Promise.all(filePaths.map(async (filePath) => {
+ const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
+ return fileContext.repoPath;
+ }))));
+ validateRepositoryFilePaths(repoRoot, repoPaths);
+ await git.raw(['restore', '--staged', '--', ...repoPaths]).catch(async () => {
+ await git.raw(['reset', 'HEAD', '--', ...repoPaths]);
+ });
+ });
+}
+
+export async function commit(directory, message, options = {}) {
+ return withGitIndexMutationQueue(directory, async () => {
+ const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
+ let temporarilyUnstagedFiles = [];
+
+ try {
+ const requestedFiles = Array.isArray(options.files)
+ ? options.files
+ .map((value) => String(value || '').trim())
+ .filter(Boolean)
+ : [];
+ const requestedStageFiles = Array.isArray(options.stageFiles)
+ ? options.stageFiles
+ .map((value) => String(value || '').trim())
+ .filter(Boolean)
+ : null;
+ let filesToCommit = [];
+ let commitFromIndexOnly = false;
+
+ if (options.addAll) {
+ await git.add('.');
+ } else if (requestedFiles.length > 0) {
+ filesToCommit = Array.from(new Set(await Promise.all(requestedFiles.map(async (filePath) => {
+ const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
+ return fileContext.repoPath;
+ }))));
+
+ const stageFilesToCommit = requestedStageFiles
+ ? Array.from(new Set(await Promise.all(requestedStageFiles.map(async (filePath) => {
+ const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
+ return fileContext.repoPath;
+ }))))
+ : null;
+
+ const status = await git.status();
+ const fileStatusByPath = new Map(status.files.map((file) => [file.path, file]));
+ filesToCommit = filesToCommit.filter((filePath) => fileStatusByPath.has(filePath));
+
+ if (filesToCommit.length === 0) {
+ throw new Error('No selected files are available to commit. Refresh git status and try again.');
+ }
+
+ if (requestedStageFiles) {
+ commitFromIndexOnly = true;
+ const selectedFileSet = new Set(filesToCommit);
+ temporarilyUnstagedFiles = status.files
+ .filter((file) => {
+ const indexStatus = (file.index || '').trim();
+ return indexStatus && indexStatus !== '?' && !selectedFileSet.has(file.path);
+ })
+ .map((file) => file.path);
+
+ if (temporarilyUnstagedFiles.length > 0) {
+ await git.raw(['restore', '--staged', '--', ...temporarilyUnstagedFiles]);
+ }
+ }
+
+ const filesNeedingAdd = requestedStageFiles
+ ? (stageFilesToCommit || []).filter((filePath) => fileStatusByPath.has(filePath))
+ : filesToCommit.filter((filePath) => {
+ const fileStatus = fileStatusByPath.get(filePath);
+ if (!fileStatus) {
+ return false;
+ }
+
+ const alreadyFullyStaged = fileStatus.index !== ' ' && fileStatus.working_dir === ' ';
+ return !alreadyFullyStaged;
+ });
+
+ if (filesNeedingAdd.length > 0) {
+ await git.raw(['add', '--', ...filesNeedingAdd]);
+ }
+ }
+
+ const commitArgs =
+ !commitFromIndexOnly && !options.addAll && filesToCommit.length > 0
+ ? filesToCommit
+ : undefined;
+
+ let result;
+ try {
+ result = await git.commit(message, commitArgs);
+ } catch (error) {
+ const gitErrorText = parseGitErrorText(error);
+ const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
+ if (!isPathspecError || !commitArgs || commitArgs.length === 0) {
+ throw error;
+ }
+
+ // Fallback for deleted/stale selections: commit currently staged changes.
+ result = await git.commit(message);
+ }
+
+ if (temporarilyUnstagedFiles.length > 0) {
+ await git.raw(['add', '--', ...temporarilyUnstagedFiles]).catch((restoreError) => {
+ console.error('Failed to restore temporarily unstaged files:', restoreError);
+ });
+ }
+
+ return {
+ success: true,
+ commit: result.commit,
+ branch: result.branch,
+ summary: result.summary
+ };
+ } catch (error) {
+ if (temporarilyUnstagedFiles.length > 0) {
+ await git.raw(['add', '--', ...temporarilyUnstagedFiles]).catch((restoreError) => {
+ console.error('Failed to restore temporarily unstaged files after commit failure:', restoreError);
+ });
+ }
+ console.error('Failed to commit:', error);
+ throw error;
+ }
+ });
}
export async function getBranches(directory) {
diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js
index 44c87348..df61bba5 100644
--- a/packages/web/server/lib/git/service.test.js
+++ b/packages/web/server/lib/git/service.test.js
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { resolveBaseRefForLog } from './service.js';
+import { resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js';
describe('resolveBaseRefForLog', () => {
it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
@@ -37,3 +37,13 @@ describe('resolveBaseRefForLog', () => {
expect(await resolveBaseRefForLog(' ', checkRef)).toBeUndefined();
});
});
+
+describe('git index path validation', () => {
+ it('rejects stage paths outside the repository before invoking git', async () => {
+ await expect(stageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
+ });
+
+ it('rejects unstage paths outside the repository before invoking git', async () => {
+ await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
+ });
+});
diff --git a/packages/web/src/api/git.test.ts b/packages/web/src/api/git.test.ts
new file mode 100644
index 00000000..ae41a475
--- /dev/null
+++ b/packages/web/src/api/git.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({
+ checkIsGitRepository: vi.fn(),
+ getGitStatus: vi.fn(),
+ getGitDiff: vi.fn(),
+ getGitFileDiff: vi.fn(),
+ revertGitFile: vi.fn(),
+ stageGitFile: vi.fn(),
+ stageGitFiles: vi.fn(),
+ unstageGitFile: vi.fn(),
+ unstageGitFiles: vi.fn(),
+ isLinkedWorktree: vi.fn(),
+ getGitBranches: vi.fn(),
+ deleteGitBranch: vi.fn(),
+ deleteRemoteBranch: vi.fn(),
+ removeRemote: vi.fn(),
+ generateCommitMessage: vi.fn(),
+ generatePullRequestDescription: vi.fn(),
+ listGitWorktrees: vi.fn(),
+ validateGitWorktree: vi.fn(),
+ createGitWorktree: vi.fn(),
+ deleteGitWorktree: vi.fn(),
+ validateWorktreeDirectory: vi.fn(),
+ canonicalizeWorktreeState: vi.fn(),
+ createGitCommit: vi.fn(),
+ gitPush: vi.fn(),
+ gitPull: vi.fn(),
+ gitFetch: vi.fn(),
+ listGitStashes: vi.fn(),
+ countGitStashFiles: vi.fn(),
+ stashGitChanges: vi.fn(),
+ applyGitStash: vi.fn(),
+ popGitStash: vi.fn(),
+ dropGitStash: vi.fn(),
+ checkoutBranch: vi.fn(),
+ createBranch: vi.fn(),
+ renameBranch: vi.fn(),
+ getGitLog: vi.fn(),
+ getCommitFiles: vi.fn(),
+ getCurrentGitIdentity: vi.fn(),
+ hasLocalIdentity: vi.fn(),
+ setGitIdentity: vi.fn(),
+ getGitIdentities: vi.fn(),
+ createGitIdentity: vi.fn(),
+ updateGitIdentity: vi.fn(),
+ deleteGitIdentity: vi.fn(),
+ getRemotes: vi.fn(),
+ rebase: vi.fn(),
+ abortRebase: vi.fn(),
+ continueRebase: vi.fn(),
+ merge: vi.fn(),
+ abortMerge: vi.fn(),
+ continueMerge: vi.fn(),
+ stash: vi.fn(),
+ stashPop: vi.fn(),
+ getConflictDetails: vi.fn(),
+}));
+
+describe('createWebGitAPI', () => {
+ it('exposes bulk stage and unstage methods', async () => {
+ const { createWebGitAPI } = await import('./git');
+ const api = createWebGitAPI();
+
+ expect(typeof api.stageGitFiles).toBe('function');
+ expect(typeof api.unstageGitFiles).toBe('function');
+ });
+});
diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts
index d57038e2..5d5c75f6 100644
--- a/packages/web/src/api/git.ts
+++ b/packages/web/src/api/git.ts
@@ -11,6 +11,10 @@ export const createWebGitAPI = (): GitAPI => ({
getGitDiff: gitApiHttp.getGitDiff,
getGitFileDiff: gitApiHttp.getGitFileDiff,
revertGitFile: gitApiHttp.revertGitFile,
+ stageGitFile: gitApiHttp.stageGitFile,
+ stageGitFiles: gitApiHttp.stageGitFiles,
+ unstageGitFile: gitApiHttp.unstageGitFile,
+ unstageGitFiles: gitApiHttp.unstageGitFiles,
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
getGitBranches: gitApiHttp.getGitBranches,
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],