diff --git a/bun.lock b/bun.lock index 4d4d9f56..05d40141 100644 --- a/bun.lock +++ b/bun.lock @@ -641,7 +641,7 @@ "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], - "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa"], + "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa", "sha512-UJwi6aXMAiUaOvqPHVlMtCOLRa1QAU2SqYD9H07KHpN+I2mBoFuxP1HnUOkt86+j+/o/XyHpM7D33JFFQi/jfA=="], "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], diff --git a/packages/ui/src/components/chat/PendingChangesBar.tsx b/packages/ui/src/components/chat/PendingChangesBar.tsx index 656341b0..d7a5ad89 100644 --- a/packages/ui/src/components/chat/PendingChangesBar.tsx +++ b/packages/ui/src/components/chat/PendingChangesBar.tsx @@ -97,11 +97,12 @@ export const PendingChangesBar: React.FC = React.memo(() => { } const store = useUIStore.getState(); + const openStagedDiff = file.hasStagedChanges && !file.hasWorkingChanges; if (!store.isMobile) { - store.openContextDiff(currentDirectory, file.relativePath); + store.openContextDiff(currentDirectory, file.relativePath, openStagedDiff); return; } - store.navigateToDiff(file.relativePath); + store.navigateToDiff(file.relativePath, openStagedDiff); store.setRightSidebarOpen(false); }; diff --git a/packages/ui/src/components/chat/changedFiles.ts b/packages/ui/src/components/chat/changedFiles.ts index 79545c3c..fc8731ce 100644 --- a/packages/ui/src/components/chat/changedFiles.ts +++ b/packages/ui/src/components/chat/changedFiles.ts @@ -16,6 +16,8 @@ export interface GitChangedFile { insertions: number; deletions: number; status: string; + hasStagedChanges: boolean; + hasWorkingChanges: boolean; } export type ChangedFileEntry = ChangedFile | GitChangedFile; @@ -153,8 +155,12 @@ export const extractGitChangedFiles = ( ): GitChangedFile[] => { const result: GitChangedFile[] = []; for (const file of files) { - const code = file.working_dir !== ' ' ? file.working_dir : file.index; - if (code === '!' || code === ' ') continue; + const indexStatus = file.index?.trim() ?? ''; + const workingStatus = file.working_dir?.trim() ?? ''; + const hasStagedChanges = Boolean(indexStatus && indexStatus !== '?'); + const hasWorkingChanges = Boolean(workingStatus || indexStatus === '?'); + const code = workingStatus || indexStatus; + if (!code || code === '!') continue; const stats = diffStats?.[file.path]; result.push({ path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path, @@ -162,6 +168,8 @@ export const extractGitChangedFiles = ( insertions: stats?.insertions ?? 0, deletions: stats?.deletions ?? 0, status: code, + hasStagedChanges, + hasWorkingChanges, }); } return result; diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 9024a61d..5e0db9ea 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -264,7 +264,7 @@ const getFileNameFromPath = (path: string | null): string | null => { }; const getTabLabel = ( - tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null }, + tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; stagedDiff?: boolean }, t: TranslateFn ): string => { if (tab.label) { @@ -288,6 +288,10 @@ const getTabLabel = ( return t('contextPanel.mode.preview'); } + if (tab.mode === 'diff') { + return tab.stagedDiff ? t('contextPanel.mode.stagedDiff') : t('contextPanel.mode.workingDiff'); + } + return getModeLabel(tab.mode, t); }; @@ -1554,7 +1558,6 @@ export const ContextPanel: React.FC = () => { const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth); const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab); const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs); - const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile); const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath); const openContextPreview = useUIStore((state) => state.openContextPreview); const { themeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem(); @@ -1690,10 +1693,7 @@ export const ContextPanel: React.FC = () => { return; } - if (activeTab.mode === 'diff' && activeTab.targetPath) { - setPendingDiffFile(activeTab.targetPath); - } - }, [activeTab, directoryKey, setPendingDiffFile, setSelectedFilePath]); + }, [activeTab, directoryKey, setSelectedFilePath]); const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null; @@ -1797,7 +1797,18 @@ export const ContextPanel: React.FC = () => { }), [effectiveDirectory, t, tabs]); const activeNonChatContent = activeTab?.mode === 'diff' - ? + ? ( + + ) : activeTab?.mode === 'context' ? : activeTab?.mode === 'plan' diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 74804446..699d56a1 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -52,6 +52,7 @@ type FileEntry = GitStatus['files'][number] & { }; type DiffData = { original: string; modified: string; isBinary?: boolean }; +type DiffScope = 'all' | 'staged' | 'working'; const BinaryDiffPlaceholder = React.memo(() => { const { t } = useI18n(); @@ -118,6 +119,16 @@ const isNewStatusFile = (file: GitStatus['files'][number]): boolean => { return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?'; }; +const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => { + const indexCode = file.index?.trim(); + return Boolean(indexCode && indexCode !== '?'); +}; + +const isWorkingStatusFile = (file: GitStatus['files'][number]): boolean => { + const workingCode = file.working_dir?.trim(); + return Boolean(workingCode) || file.index === '?'; +}; + const isAbsolutePath = (value: string): boolean => { return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value); }; @@ -622,6 +633,8 @@ interface MultiFileDiffEntryProps { showOpenInEditorAction?: boolean; isOpeningInEditor?: boolean; onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void; + staged?: boolean; + stagedRevision?: number; } const MultiFileDiffEntry = React.memo(({ @@ -639,6 +652,8 @@ const MultiFileDiffEntry = React.memo(({ showOpenInEditorAction = false, isOpeningInEditor = false, onOpenInEditor, + staged = false, + stagedRevision = 0, }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); @@ -656,6 +671,7 @@ const MultiFileDiffEntry = React.memo(({ const [diffLoadError, setDiffLoadError] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); const [forceRenderLarge, setForceRenderLarge] = React.useState(false); + const [stagedDiffData, setStagedDiffData] = React.useState(null); const lastDiffRequestRef = React.useRef(null); const sectionRef = React.useRef(null); @@ -663,9 +679,10 @@ const MultiFileDiffEntry = React.memo(({ const renderSideBySide = layout === 'side-by-side'; const diffData = React.useMemo(() => { + if (staged) return stagedDiffData; if (!cachedDiff) return null; return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary }; - }, [cachedDiff]); + }, [cachedDiff, staged, stagedDiffData]); const setSectionRef = React.useCallback((node: HTMLDivElement | null) => { sectionRef.current = node; @@ -716,6 +733,16 @@ const MultiFileDiffEntry = React.memo(({ setHasBeenVisible(true); }, [expandRequestNonce, expandRequestPath, file.path]); + React.useEffect(() => { + if (!staged) { + return; + } + + setStagedDiffData(null); + setDiffLoadError(null); + lastDiffRequestRef.current = null; + }, [staged, stagedRevision]); + React.useEffect(() => { if (!isExpanded || !hasBeenVisible) return; if (!directory || diffData) { @@ -724,7 +751,7 @@ const MultiFileDiffEntry = React.memo(({ return; } - const requestKey = `${directory}::${file.path}::${diffRetryNonce}`; + const requestKey = `${directory}::${file.path}::${staged ? `staged:${stagedRevision}` : 'unstaged'}::${diffRetryNonce}`; if (lastDiffRequestRef.current === requestKey) { return; } @@ -733,7 +760,7 @@ const MultiFileDiffEntry = React.memo(({ setIsLoading(true); let cancelled = false; - const fetchPromise = git.getGitFileDiff(directory, { path: file.path }); + const fetchPromise = git.getGitFileDiff(directory, { path: file.path, staged }); const timeoutMs = DIFF_REQUEST_TIMEOUT_MS; const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); @@ -743,11 +770,16 @@ const MultiFileDiffEntry = React.memo(({ .then((response) => { if (cancelled) return; - setDiff(directory, file.path, { + const nextDiff = { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary, - }); + }; + if (staged) { + setStagedDiffData(nextDiff); + } else { + setDiff(directory, file.path, nextDiff); + } setIsLoading(false); }) .catch((error) => { @@ -763,7 +795,7 @@ const MultiFileDiffEntry = React.memo(({ lastDiffRequestRef.current = null; } }; - }, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff]); + }, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff, staged, stagedRevision]); const handleToggle = React.useCallback(() => { handleOpenChange(!isExpanded); @@ -936,6 +968,8 @@ interface DiffViewProps { hideFileSelector?: boolean; pinSelectedFileHeaderToTopOnNavigate?: boolean; showOpenInEditorAction?: boolean; + diffScope?: DiffScope; + targetFilePath?: string | null; } export const DiffView: React.FC = ({ @@ -944,6 +978,8 @@ export const DiffView: React.FC = ({ hideFileSelector = false, pinSelectedFileHeaderToTopOnNavigate = false, showOpenInEditorAction = false, + diffScope = 'all', + targetFilePath = null, }) => { const { t } = useI18n(); const { git, files } = useRuntimeAPIs(); @@ -957,8 +993,14 @@ export const DiffView: React.FC = ({ const ensureStatus = useGitStore((state) => state.ensureStatus); const fetchStatus = useGitStore((state) => state.fetchStatus); const setDiff = useGitStore((state) => state.setDiff); + const indexRevision = useGitStore(React.useCallback((state) => { + if (!effectiveDirectory) return 0; + return state.directories.get(effectiveDirectory)?.indexRevision ?? 0; + }, [effectiveDirectory])); const [selectedFile, setSelectedFile] = React.useState(null); + const [selectedFileStaged, setSelectedFileStaged] = React.useState(false); + const [selectedStagedDiffData, setSelectedStagedDiffData] = React.useState(null); const [stackedExpandTarget, setStackedExpandTarget] = React.useState(null); const [stackedExpandRequestNonce, setStackedExpandRequestNonce] = React.useState(0); const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState(null); @@ -967,6 +1009,7 @@ export const DiffView: React.FC = ({ const lastDiffRequestRef = React.useRef(null); const pendingDiffFile = useUIStore((state) => state.pendingDiffFile); + const pendingDiffStaged = useUIStore((state) => state.pendingDiffStaged); const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile); const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference); const diffFileLayout = useUIStore((state) => state.diffFileLayout); @@ -977,6 +1020,8 @@ export const DiffView: React.FC = ({ const setDiffViewMode = useUIStore((state) => state.setDiffViewMode); const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine); const diffWrapLines = diffWrapLinesStore; + const forcedStaged = diffScope === 'staged' ? true : diffScope === 'working' ? false : null; + const activeDiffStaged = forcedStaged ?? selectedFileStaged; const isStackedView = diffViewMode === 'stacked'; const isMobileLayout = isMobile || screenWidth <= 768; @@ -1085,8 +1130,14 @@ export const DiffView: React.FC = ({ const changedFiles: FileEntry[] = React.useMemo(() => { if (!status?.files) return []; const diffStats = status.diffStats ?? {}; + const includeFile = diffScope === 'staged' + ? isStagedStatusFile + : diffScope === 'working' + ? isWorkingStatusFile + : () => true; return status.files + .filter(includeFile) .map((file) => ({ ...file, insertions: diffStats[file.path]?.insertions ?? 0, @@ -1094,7 +1145,7 @@ export const DiffView: React.FC = ({ isNew: isNewStatusFile(file), })) .sort((a, b) => a.path.localeCompare(b.path)); - }, [status]); + }, [diffScope, status]); const selectedFileEntry = React.useMemo(() => { if (!selectedFile) return null; @@ -1149,8 +1200,14 @@ export const DiffView: React.FC = ({ // Handle pending diff file from external navigation React.useEffect(() => { + if (diffScope !== 'all') { + return; + } + if (pendingDiffFile) { setSelectedFile(pendingDiffFile); + setSelectedFileStaged(pendingDiffStaged); + setSelectedStagedDiffData(null); setPendingDiffFile(null); if (isStackedView) { shouldPinAfterAlignRef.current = true; @@ -1159,7 +1216,39 @@ export const DiffView: React.FC = ({ setStackedExpandRequestNonce((nonce) => nonce + 1); } } - }, [isStackedView, pendingDiffFile, setPendingDiffFile]); + }, [diffScope, isStackedView, pendingDiffFile, pendingDiffStaged, setPendingDiffFile]); + + React.useEffect(() => { + if (diffScope === 'all') { + return; + } + + const normalizedTarget = targetFilePath?.trim(); + if (!normalizedTarget) { + return; + } + + setSelectedFile(normalizedTarget); + setSelectedFileStaged(diffScope === 'staged'); + setSelectedStagedDiffData(null); + + if (isStackedView) { + shouldPinAfterAlignRef.current = true; + pendingScrollTargetRef.current = normalizedTarget; + setStackedExpandTarget(normalizedTarget); + setStackedExpandRequestNonce((nonce) => nonce + 1); + } + }, [diffScope, isStackedView, targetFilePath]); + + React.useEffect(() => { + if (!activeDiffStaged) { + return; + } + + setSelectedStagedDiffData(null); + setDiffLoadError(null); + lastDiffRequestRef.current = null; + }, [activeDiffStaged, indexRevision]); // Auto-select first file (skip if we have a pending file to consume) React.useEffect(() => { @@ -1355,6 +1444,8 @@ export const DiffView: React.FC = ({ const handleSelectFile = React.useCallback((value: string) => { setSelectedFile(value); + setSelectedFileStaged(false); + setSelectedStagedDiffData(null); }, []); const handleSelectFileAndScroll = React.useCallback((value: string) => { @@ -1365,6 +1456,8 @@ export const DiffView: React.FC = ({ pendingScrollTargetRef.current = null; setSelectedFile(value); + setSelectedFileStaged(false); + setSelectedStagedDiffData(null); if (!isStackedView) { shouldPinAfterAlignRef.current = false; @@ -1405,14 +1498,15 @@ export const DiffView: React.FC = ({ const showFileSelector = !hideFileSelector && (!isStackedView || !showFileSidebar); const selectedCachedDiff = useGitStore(React.useCallback((state) => { - if (!effectiveDirectory || !selectedFile) return null; + if (!effectiveDirectory || !selectedFile || activeDiffStaged) return null; return state.directories.get(effectiveDirectory)?.diffCache.get(selectedFile) ?? null; - }, [effectiveDirectory, selectedFile])); + }, [activeDiffStaged, effectiveDirectory, selectedFile])); const selectedDiffData = React.useMemo(() => { + if (activeDiffStaged) return selectedStagedDiffData; if (!selectedCachedDiff) return null; return { original: selectedCachedDiff.original, modified: selectedCachedDiff.modified, isBinary: selectedCachedDiff.isBinary }; - }, [selectedCachedDiff]); + }, [activeDiffStaged, selectedCachedDiff, selectedStagedDiffData]); const [openingEditorFilePath, setOpeningEditorFilePath] = React.useState(null); @@ -1433,6 +1527,7 @@ export const DiffView: React.FC = ({ try { const patchResponse = await git.getGitDiff(effectiveDirectory, { path: filePath, + staged: activeDiffStaged, contextLines: 3, }); targetLine = getFirstVisibleModifiedLineFromPatch(patchResponse.diff); @@ -1443,13 +1538,15 @@ export const DiffView: React.FC = ({ let diffForNavigation = cachedDiffData; if (targetLine === null || !diffForNavigation) { - const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath }); + const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath, staged: activeDiffStaged }); diffForNavigation = { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary, }; - setDiff(effectiveDirectory, filePath, diffForNavigation); + if (!activeDiffStaged) { + setDiff(effectiveDirectory, filePath, diffForNavigation); + } } const resolvedTargetLine = targetLine ?? ((diffForNavigation.isBinary || isImageFile(filePath)) @@ -1472,7 +1569,7 @@ export const DiffView: React.FC = ({ } finally { setOpeningEditorFilePath((current) => (current === filePath ? null : current)); } - }, [effectiveDirectory, files, git, openContextFileAtLine, setDiff]); + }, [activeDiffStaged, effectiveDirectory, files, git, openContextFileAtLine, setDiff]); const openSelectedFileInEditorAtChange = React.useCallback(async () => { if (!selectedFile) { @@ -1484,7 +1581,7 @@ export const DiffView: React.FC = ({ const isOpeningSelectedInEditor = Boolean(selectedFile && openingEditorFilePath === selectedFile); - const hasCurrentDiff = !!selectedCachedDiff; + const hasCurrentDiff = activeDiffStaged ? !!selectedStagedDiffData : !!selectedCachedDiff; const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff; React.useEffect(() => { @@ -1499,19 +1596,19 @@ export const DiffView: React.FC = ({ return; } - if (selectedCachedDiff) { + if (activeDiffStaged ? selectedStagedDiffData : selectedCachedDiff) { lastDiffRequestRef.current = null; return; } - const requestKey = `${effectiveDirectory}::${selectedFile}::${diffRetryNonce}`; + const requestKey = `${effectiveDirectory}::${selectedFile}::${activeDiffStaged ? `staged:${indexRevision}` : 'unstaged'}::${diffRetryNonce}`; if (lastDiffRequestRef.current === requestKey) { return; } lastDiffRequestRef.current = requestKey; let cancelled = false; - const fetchPromise = git.getGitFileDiff(effectiveDirectory, { path: selectedFile }); + const fetchPromise = git.getGitFileDiff(effectiveDirectory, { path: selectedFile, staged: activeDiffStaged }); const timeoutMs = DIFF_REQUEST_TIMEOUT_MS; const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); @@ -1521,11 +1618,16 @@ export const DiffView: React.FC = ({ .then((response) => { if (cancelled) return; - setDiff(effectiveDirectory, selectedFile, { + const nextDiff = { original: response.original ?? '', modified: response.modified ?? '', isBinary: response.isBinary, - }); + }; + if (activeDiffStaged) { + setSelectedStagedDiffData(nextDiff); + } else { + setDiff(effectiveDirectory, selectedFile, nextDiff); + } }) .catch((error) => { if (cancelled) return; @@ -1540,7 +1642,7 @@ export const DiffView: React.FC = ({ lastDiffRequestRef.current = null; } }; - }, [effectiveDirectory, isStackedView, selectedFile, selectedCachedDiff, git, setDiff, diffRetryNonce]); + }, [activeDiffStaged, effectiveDirectory, indexRevision, isStackedView, selectedFile, selectedCachedDiff, selectedStagedDiffData, git, setDiff, diffRetryNonce]); // Render only the selected diff viewer to prevent memory bloat with many files const renderSelectedDiffViewer = () => { @@ -1562,6 +1664,12 @@ export const DiffView: React.FC = ({ if (!effectiveDirectory) return null; const defaultExpandedCount = getStackedViewDefaultExpandedCount(changedFiles.length); + const getFileStaged = (path: string) => { + if (forcedStaged !== null) { + return forcedStaged; + } + return selectedFileStaged && path === selectedFile; + }; return (
@@ -1591,7 +1699,7 @@ export const DiffView: React.FC = ({
{changedFiles.map((file, index) => ( = ({ onOpenInEditor={(filePath, diffData) => { void openFileInEditorAtChange(filePath, diffData); }} + staged={getFileStaged(file.path)} + stagedRevision={indexRevision} /> ))}
diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index fd356212..96987710 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useFireworksCelebration } from '@/contexts/FireworksContext'; -import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types'; +import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types'; import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore'; import { useShallow } from 'zustand/react/shallow'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; @@ -47,7 +47,7 @@ import { IntegrateCommitsSection } from './git/IntegrateCommitsSection'; import { GitHeader } from './git/GitHeader'; import { StashesDialog } from './git/StashesDialog'; -import { ChangesSection } from './git/ChangesSection'; +import { ChangesPanel, type ChangesGroupConfig } from './git/ChangesPanel'; import { CommitSection } from './git/CommitSection'; import { GitEmptyState } from './git/GitEmptyState'; import { HistorySection } from './git/HistorySection'; @@ -56,6 +56,7 @@ import { ConflictDialog } from './git/ConflictDialog'; import { StashDialog } from './git/StashDialog'; import { InProgressOperationBanner } from './git/InProgressOperationBanner'; import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection'; +import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue'; import type { GitRemote } from '@/lib/gitApi'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { cn } from '@/lib/utils'; @@ -74,13 +75,13 @@ type HistoryBranchDivider = { } | null; const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab'; +const GIT_RECONCILE_DELAY_MS = 15000; const isActionTab = (value: unknown): value is ActionTab => value === 'commit' || value === 'branch' || value === 'pr'; type GitViewSnapshot = { directory?: string; - selectedPaths: string[]; commitMessage: string; generatedHighlights: string[]; }; @@ -220,6 +221,17 @@ const gitViewSnapshots = new Map(); const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/, ''); +const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => { + const indexStatus = file.index?.trim(); + return Boolean(indexStatus && indexStatus !== '?'); +}; + +const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => { + const workingStatus = file.working_dir?.trim(); + const indexStatus = file.index?.trim(); + return Boolean(workingStatus || indexStatus === '?'); +}; + export const GitView: React.FC = () => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); @@ -304,11 +316,116 @@ export const GitView: React.FC = () => { const fetchIdentity = useGitStore((state) => state.fetchIdentity); const prefetchDiffs = useGitStore((state) => state.prefetchDiffs); const setLogMaxCount = useGitStore((state) => state.setLogMaxCount); + const moveStatusPathsOptimistically = useGitStore((state) => state.moveStatusPathsOptimistically); + const restoreStatus = useGitStore((state) => state.restoreStatus); + const bumpIndexRevision = useGitStore((state) => state.bumpIndexRevision); const isMobile = useUIStore((state) => state.isMobile); const openContextDiff = useUIStore((state) => state.openContextDiff); const navigateToDiff = useUIStore((state) => state.navigateToDiff); const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen); const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null); + const gitReconcileTimeoutRef = React.useRef(null); + const gitMutationFlushTimeoutRef = React.useRef(null); + const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null); + + const clearScheduledGitReconcile = React.useCallback(() => { + if (gitReconcileTimeoutRef.current === null) { + return; + } + + window.clearTimeout(gitReconcileTimeoutRef.current); + gitReconcileTimeoutRef.current = null; + }, []); + + const scheduleGitReconcile = React.useCallback((directory: string) => { + clearScheduledGitReconcile(); + gitReconcileTimeoutRef.current = window.setTimeout(() => { + gitReconcileTimeoutRef.current = null; + if (normalizePath(directory) !== normalizePath(currentDirectory)) { + return; + } + void fetchStatus(directory, git, { silent: true }); + }, GIT_RECONCILE_DELAY_MS); + }, [clearScheduledGitReconcile, currentDirectory, fetchStatus, git]); + + React.useEffect(() => clearScheduledGitReconcile, [clearScheduledGitReconcile]); + + const clearScheduledGitMutationFlush = React.useCallback(() => { + if (gitMutationFlushTimeoutRef.current === null) { + return; + } + + window.clearTimeout(gitMutationFlushTimeoutRef.current); + gitMutationFlushTimeoutRef.current = null; + }, []); + + const scheduleGitMutationFlush = React.useCallback(() => { + if (gitMutationFlushTimeoutRef.current !== null) { + return; + } + + gitMutationFlushTimeoutRef.current = window.setTimeout(() => { + gitMutationFlushTimeoutRef.current = null; + flushQueuedGitMutationsRef.current?.(); + }, 0); + }, []); + + const runGitIndexMutation = React.useCallback(async ( + directory: string, + direction: GitIndexMutationDirection, + paths: string[] + ) => { + if (direction === 'stage') { + if (git.stageGitFiles) { + await git.stageGitFiles(directory, paths); + return; + } + await Promise.all(paths.map((filePath) => git.stageGitFile(directory, filePath))); + return; + } + + if (git.unstageGitFiles) { + await git.unstageGitFiles(directory, paths); + return; + } + await Promise.all(paths.map((filePath) => git.unstageGitFile(directory, filePath))); + }, [git]); + + const gitIndexMutationQueue = React.useMemo(() => createGitIndexMutationQueue({ + runMutation: ({ directory, direction, paths }) => runGitIndexMutation(directory, direction, paths), + onMutationComplete: ({ directory }) => { + bumpIndexRevision(directory); + scheduleGitReconcile(directory); + }, + onMutationError: ({ directory, direction, rollback }, error) => { + rollback?.(); + bumpIndexRevision(directory); + scheduleGitReconcile(directory); + const fallback = direction === 'stage' + ? t('gitView.toast.stageFileFailed') + : t('gitView.toast.unstageFileFailed'); + toast.error(error instanceof Error ? error.message : fallback); + }, + onPathsComplete: (paths) => { + setMovingChangePaths((previous) => { + const updated = new Set(previous); + paths.forEach((path) => updated.delete(path)); + return updated; + }); + }, + scheduleFlush: scheduleGitMutationFlush, + }), [bumpIndexRevision, runGitIndexMutation, scheduleGitMutationFlush, scheduleGitReconcile, t]); + + React.useEffect(() => { + flushQueuedGitMutationsRef.current = gitIndexMutationQueue.flush; + return () => { + flushQueuedGitMutationsRef.current = null; + }; + }, [gitIndexMutationQueue]); + + React.useEffect(() => () => gitIndexMutationQueue.clear(), [gitIndexMutationQueue]); + + React.useEffect(() => clearScheduledGitMutationFlush, [clearScheduledGitMutationFlush]); React.useEffect(() => { if (!currentDirectory) { @@ -442,17 +559,15 @@ export const GitView: React.FC = () => { } }, []); - const [selectedPaths, setSelectedPaths] = React.useState>( - () => new Set(initialSnapshot?.selectedPaths ?? []) - ); - const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false); const [revertingPaths, setRevertingPaths] = React.useState>(new Set()); + const [movingChangePaths, setMovingChangePaths] = React.useState>(new Set()); const [isRevertingAll, setIsRevertingAll] = React.useState(false); const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0); const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false); const [generatedHighlights, setGeneratedHighlights] = React.useState( initialSnapshot?.generatedHighlights ?? [] ); + const hasPendingIndexMutation = movingChangePaths.size > 0 || gitIndexMutationQueue.size() > 0 || gitIndexMutationQueue.isRunning(); const scrollActionPanelToBottom = React.useCallback(() => { const scrollTarget = actionPanelScrollRef.current; @@ -662,11 +777,10 @@ export const GitView: React.FC = () => { if (!currentDirectory) return; gitViewSnapshots.set(currentDirectory, { directory: currentDirectory, - selectedPaths: Array.from(selectedPaths), commitMessage, generatedHighlights, }); - }, [commitMessage, currentDirectory, selectedPaths, generatedHighlights]); + }, [commitMessage, currentDirectory, generatedHighlights]); React.useEffect(() => { loadProfiles(); @@ -844,6 +958,16 @@ export const GitView: React.FC = () => { return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path)); }, [status]); + const stagedChangeEntries = React.useMemo( + () => changeEntries.filter(isStagedStatusFile), + [changeEntries] + ); + + const unstagedChangeEntries = React.useMemo( + () => changeEntries.filter(isUnstagedStatusFile), + [changeEntries] + ); + React.useEffect(() => { if (!currentDirectory || changeEntries.length === 0) { return; @@ -860,7 +984,7 @@ export const GitView: React.FC = () => { orderedPaths.push(path); }; - Array.from(selectedPaths).forEach(pushPath); + stagedChangeEntries.forEach((entry) => pushPath(entry.path)); visibleChangePaths.forEach(pushPath); changeEntries.slice(0, GIT_DIFF_PRIORITY_BASELINE_LIMIT).forEach((entry) => pushPath(entry.path)); @@ -875,30 +999,7 @@ export const GitView: React.FC = () => { return () => { window.clearTimeout(timeoutId); }; - }, [changeEntries, currentDirectory, git, prefetchDiffs, selectedPaths, visibleChangePaths]); - - React.useEffect(() => { - if (!status || changeEntries.length === 0) { - setSelectedPaths(new Set()); - setHasUserAdjustedSelection(false); - return; - } - - setSelectedPaths((previous) => { - const next = new Set(); - const previousSet = previous ?? new Set(); - - for (const file of changeEntries) { - if (previousSet.has(file.path)) { - next.add(file.path); - } else if (!hasUserAdjustedSelection) { - next.add(file.path); - } - } - - return next; - }); - }, [status, changeEntries, hasUserAdjustedSelection]); + }, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]); const handleSyncAction = async (action: Exclude, remote?: GitRemote) => { if (!currentDirectory) return; @@ -1027,9 +1128,9 @@ export const GitView: React.FC = () => { return; } - const filesToCommit = Array.from(selectedPaths).sort(); + const filesToCommit = stagedChangeEntries.map((file) => file.path).sort(); if (filesToCommit.length === 0) { - toast.error(t('gitView.toast.selectFileToCommit')); + toast.error(t('gitView.toast.stageFileToCommit')); return; } @@ -1039,11 +1140,11 @@ export const GitView: React.FC = () => { try { await git.createGitCommit(currentDirectory, commitMessage.trim(), { files: filesToCommit, + stageFiles: [], }); + bumpIndexRevision(currentDirectory); toast.success(t('gitView.toast.commitCreated')); setCommitMessage(''); - setSelectedPaths(new Set()); - setHasUserAdjustedSelection(false); clearGeneratedHighlights(); await refreshStatusAndBranches(); @@ -1120,19 +1221,20 @@ export const GitView: React.FC = () => { const handleGenerateCommitMessage = React.useCallback(async () => { if (!currentDirectory) return; - if (selectedPaths.size === 0) { - toast.error(t('gitView.toast.selectFileToDescribe')); + const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort(); + if (selectedFilePaths.length === 0) { + toast.error(t('gitView.toast.stageFileToDescribe')); return; } console.error('[git-generation][browser] generate button clicked', { directory: currentDirectory, - selectedFiles: selectedPaths.size, + selectedFiles: selectedFilePaths.length, }); setIsGeneratingMessage(true); try { - const { message } = await generateSessionCommitMessage(currentDirectory, Array.from(selectedPaths)); + const { message } = await generateSessionCommitMessage(currentDirectory, selectedFilePaths); const subject = message.subject?.trim() ?? ''; const highlights = Array.isArray(message.highlights) ? message.highlights : []; @@ -1163,7 +1265,7 @@ export const GitView: React.FC = () => { } finally { setIsGeneratingMessage(false); } - }, [currentDirectory, selectedPaths, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]); + }, [currentDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]); const formatBlockingReason = (reason: ReturnType[number]): string => { if (reason.reason === 'attention') { @@ -1477,7 +1579,7 @@ export const GitView: React.FC = () => { return globalIdentity ?? null; }, [currentIdentity, profiles, globalIdentity]); - const selectedCount = selectedPaths.size; + const stagedCount = stagedChangeEntries.length; const isBusy = isLoading || syncAction !== null || commitAction !== null; const currentBranch = status?.current ?? null; const canShowIntegrateCommitsSection = Boolean( @@ -1571,29 +1673,25 @@ export const GitView: React.FC = () => { }, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]); // Keep these sections stable in layout; individual cards render placeholders when unavailable. - const toggleFileSelection = (path: string) => { - setSelectedPaths((previous) => { + const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => { + if (!currentDirectory || paths.length === 0) return; + const uniquePaths = Array.from(new Set(paths)); + setMovingChangePaths((previous) => { const next = new Set(previous); - if (next.has(path)) { - next.delete(path); - } else { - next.add(path); - } + uniquePaths.forEach((path) => next.add(path)); return next; }); - setHasUserAdjustedSelection(true); - }; + const previousStatus = moveStatusPathsOptimistically(currentDirectory, uniquePaths, direction); - const selectAll = () => { - const next = new Set(changeEntries.map((file) => file.path)); - setSelectedPaths(next); - setHasUserAdjustedSelection(true); - }; + gitIndexMutationQueue.enqueue({ + directory: currentDirectory, + direction, + paths: new Set(uniquePaths), + rollback: () => restoreStatus(currentDirectory, previousStatus), + }); - const clearSelection = () => { - setSelectedPaths(new Set()); - setHasUserAdjustedSelection(true); - }; + scheduleGitMutationFlush(); + }, [currentDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]); const handleRevertFile = React.useCallback( async (filePath: string) => { @@ -1606,7 +1704,7 @@ export const GitView: React.FC = () => { }); try { - await git.revertGitFile(currentDirectory, filePath); + await git.revertGitFile(currentDirectory, filePath, { scope: 'working' }); toast.success(t('gitView.toast.revertedFile', { path: filePath })); await refreshStatusAndBranches(false); } catch (err) { @@ -1630,6 +1728,8 @@ export const GitView: React.FC = () => { } const uniquePaths = Array.from(new Set(paths)); + const stagedPaths = new Set(stagedChangeEntries.map((entry) => entry.path)); + const touchesStagedIndex = uniquePaths.some((path) => stagedPaths.has(path)); setIsRevertingAll(true); setRevertingPaths((previous) => { const next = new Set(previous); @@ -1651,6 +1751,10 @@ export const GitView: React.FC = () => { } })); + if (touchesStagedIndex && failed.length < uniquePaths.length) { + bumpIndexRevision(currentDirectory); + } + await refreshStatusAndBranches(false); if (failed.length === 0) { @@ -1678,9 +1782,67 @@ export const GitView: React.FC = () => { setIsRevertingAll(false); } }, - [currentDirectory, git, isRevertingAll, refreshStatusAndBranches, t] + [bumpIndexRevision, currentDirectory, git, isRevertingAll, refreshStatusAndBranches, stagedChangeEntries, t] ); + const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => { + if (currentDirectory && !isMobile) { + openContextDiff(currentDirectory, path, staged); + return; + } + navigateToDiff(path, staged); + if (isMobile) { + setRightSidebarOpen(false); + } + }, [currentDirectory, isMobile, navigateToDiff, openContextDiff, setRightSidebarOpen]); + + const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []); + + const changeGroups = React.useMemo(() => { + const groups: ChangesGroupConfig[] = []; + + if (stagedChangeEntries.length > 0) { + groups.push({ + id: 'staged', + title: t('gitView.changes.stagedTitle'), + entries: stagedChangeEntries, + actionSymbol: '-', + actionAllLabel: t('gitView.changes.unstageAllAria'), + getActionLabel: (path) => t('gitView.changes.unstageFileAria', { path }), + onActionFile: (path) => void moveChangePaths([path], 'unstage'), + onActionAll: (paths) => void moveChangePaths(paths, 'unstage'), + onViewDiff: (path) => handleViewChangeDiff(path, true), + onRevertFile: handleRevertFile, + showRevertActions: false, + accent: true, + }); + } + + if (unstagedChangeEntries.length > 0) { + groups.push({ + id: 'unstaged', + title: t('gitView.changes.title'), + entries: unstagedChangeEntries, + actionSymbol: '+', + actionAllLabel: t('gitView.changes.stageAllAria'), + getActionLabel: (path) => t('gitView.changes.stageFileAria', { path }), + onActionFile: (path) => void moveChangePaths([path], 'stage'), + onActionAll: (paths) => void moveChangePaths(paths, 'stage'), + onViewDiff: (path) => handleViewChangeDiff(path, false), + onRevertFile: handleRevertFile, + }); + } + + return groups; + }, [ + handleRevertFile, + handleViewChangeDiff, + moveChangePaths, + stagedChangeEntries, + t, + unstagedChangeEntries, + ]); + const handleInsertHighlights = React.useCallback((sourceHighlights: string[]) => { if (sourceHighlights.length === 0) return; const normalizedHighlights = sourceHighlights @@ -2007,6 +2169,7 @@ export const GitView: React.FC = () => { const currentBranch = status?.current; const operation = stashDialogOperation; const branch = stashDialogBranch; + const hadStagedChanges = (status?.files ?? []).some(isStagedStatusFile); // Stash changes try { @@ -2014,6 +2177,9 @@ export const GitView: React.FC = () => { message: `Auto-stash before ${operation} with ${branch}`, includeUntracked: true, }); + if (hadStagedChanges) { + bumpIndexRevision(currentDirectory); + } } catch (stashErr) { const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes'; toast.error(msg); @@ -2053,6 +2219,7 @@ export const GitView: React.FC = () => { if (restoreAfter && operationSucceeded) { try { await git.stashPop(currentDirectory); + bumpIndexRevision(currentDirectory); toast.success(t('gitView.toast.stashedRestored')); } catch (popErr) { const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed'); @@ -2069,6 +2236,7 @@ export const GitView: React.FC = () => { if (restoreAfter) { try { await git.stashPop(currentDirectory); + bumpIndexRevision(currentDirectory); } catch { // Ignore stash pop errors in this case } @@ -2076,7 +2244,7 @@ export const GitView: React.FC = () => { throw err; } }, - [currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t] + [bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t] ); if (!currentDirectory) { @@ -2155,6 +2323,7 @@ export const GitView: React.FC = () => { isApplyingIdentity={isSettingIdentity} isWorktreeMode={!!worktreeMetadata} onOpenHistory={() => setIsHistoryDialogOpen(true)} + onOpenStashes={openStashes} actionTabItems={actionTabItems} activeActionTab={actionTab} onSelectActionTab={(tabID) => setActionTab(tabID as ActionTab)} @@ -2188,37 +2357,22 @@ export const GitView: React.FC = () => { preventOverscroll > {actionTab === 'commit' ? ( -
+
{(changeEntries?.length ?? 0) > 0 ? ( <> - { - if (currentDirectory && !isMobile) { - openContextDiff(currentDirectory, path); - return; - } - navigateToDiff(path); - if (isMobile) { - setRightSidebarOpen(false); - } - }} - onRevertFile={handleRevertFile} - isRevertingAll={isRevertingAll} - onOpenStashes={() => setIsStashesDialogOpen(true)} - /> +
+ +
{ onCommit={() => handleCommit({ pushAfter: false })} onCommitAndPush={() => handleCommit({ pushAfter: true })} commitAction={commitAction} + hasPendingIndexMutation={hasPendingIndexMutation} gitmojiEnabled={settingsGitmojiEnabled} onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)} /> @@ -2339,8 +2494,12 @@ export const GitView: React.FC = () => { onOpenChange={setIsStashesDialogOpen} directory={currentDirectory} hasUncommittedChanges={(status?.files?.length ?? 0) > 0} + hasStagedChanges={stagedChangeEntries.length > 0} uncommittedFileCount={status?.files?.length ?? 0} - onChanged={async () => { + onChanged={async (change) => { + if (currentDirectory && change?.affectsIndex) { + bumpIndexRevision(currentDirectory); + } await refreshStatusAndBranches(false); await refreshLog(); }} diff --git a/packages/ui/src/components/views/git/ChangeRow.tsx b/packages/ui/src/components/views/git/ChangeRow.tsx index f10fb9d5..f3c1e665 100644 --- a/packages/ui/src/components/views/git/ChangeRow.tsx +++ b/packages/ui/src/components/views/git/ChangeRow.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useMemo } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { Checkbox } from '@/components/ui/checkbox'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Icon } from "@/components/icon/Icon"; import type { GitStatus } from '@/lib/api/types'; @@ -40,26 +39,33 @@ function describeChange(file: GitStatus['files'][number]): ChangeDescriptor { interface ChangeRowProps { file: GitStatus['files'][number]; - checked: boolean; - onToggle: () => void; + actionLabel: string; + actionSymbol: '+' | '-'; + onAction: () => void; onViewDiff: () => void; onRevert: () => void; isReverting: boolean; stats?: { insertions: number; deletions: number }; rowPaddingClassName?: string; indentPx?: number; + /** Place the stage/unstage action at the row start (flat view) instead of the end (tree view). */ + actionAtStart?: boolean; + showRevert?: boolean; } export const ChangeRow = React.memo(function ChangeRow({ file, - checked, - onToggle, + actionLabel, + actionSymbol, + onAction, onViewDiff, onRevert, isReverting, stats, rowPaddingClassName, indentPx = 0, + actionAtStart = false, + showRevert = true, }) { const descriptor = useMemo(() => describeChange(file), [file]); const { t } = useI18n(); @@ -71,13 +77,22 @@ export const ChangeRow = React.memo(function ChangeRow({ (event: React.KeyboardEvent) => { if (event.key === ' ') { event.preventDefault(); - onToggle(); + onAction(); } else if (event.key === 'Enter') { event.preventDefault(); onViewDiff(); } }, - [onToggle, onViewDiff] + [onAction, onViewDiff] + ); + + const handleActionClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onAction(); + }, + [onAction] ); const handleRevertClick = useCallback( @@ -89,6 +104,18 @@ export const ChangeRow = React.memo(function ChangeRow({ [onRevert] ); + const actionButton = ( + + ); + return (
(function ChangeRow({ onKeyDown={handleKeyDown} style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined} > -
{ e.stopPropagation(); }}> - onToggle()} - ariaLabel={t('gitView.changes.selectFileAria', { path: file.path })} - /> -
+ {actionAtStart ? actionButton : null} (function ChangeRow({ / -{deletions} - - - - - {t('gitView.changes.revertFileTooltip')} - + {showRevert ? ( + + + + + {t('gitView.changes.revertFileTooltip')} + + ) : null} + {actionAtStart ? null : actionButton}
); }); diff --git a/packages/ui/src/components/views/git/ChangesPanel.tsx b/packages/ui/src/components/views/git/ChangesPanel.tsx new file mode 100644 index 00000000..d09842f2 --- /dev/null +++ b/packages/ui/src/components/views/git/ChangesPanel.tsx @@ -0,0 +1,525 @@ +import React from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +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 { + TREE_INDENT_PX, + buildChangesTree, + flattenChangesTree, + type ChangesTreeDirectoryNode, + type FlattenedTreeRow, +} from './changesTree'; +import type { GitStatus } from '@/lib/api/types'; +import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores/useUIStore'; +import { useI18n } from '@/lib/i18n'; + +export interface ChangesGroupConfig { + /** Stable id (e.g. 'staged' | 'unstaged'). */ + id: string; + title: string; + entries: GitStatus['files']; + /** Per-file primary action: '+' stages, '-' unstages. */ + actionSymbol: '+' | '-'; + /** aria/title for the bulk header action (stage all / unstage all). */ + actionAllLabel: string; + getActionLabel: (path: string) => string; + onActionFile: (path: string) => void; + onActionAll: (paths: string[]) => void; + onViewDiff: (path: string) => void; + onRevertFile: (path: string) => void; + showRevertActions?: boolean; + /** Visually mark this group as "ready to commit". */ + accent?: boolean; +} + +interface ChangesPanelProps { + groups: ChangesGroupConfig[]; + diffStats: Record | undefined; + revertingPaths: Set; + isRevertingAll?: boolean; + onVisiblePathsChange?: (paths: string[]) => void; + /** Reverts every changed path across all groups; rendered once for the panel. */ + onRevertAll?: (paths: string[]) => Promise | void; +} + +const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 1000; +const CHANGE_ROW_ESTIMATE_PX = 34; +const VISIBLE_PREFETCH_LIMIT = 30; + +const ROW_PADDING_CLASSNAME = 'pl-0 pr-2'; + +type PanelRow = + | { type: 'header'; key: string; groupIndex: number } + | { type: 'file'; key: string; groupIndex: number; file: GitStatus['files'][number]; depth: number } + | { type: 'directory'; key: string; groupIndex: number; directory: ChangesTreeDirectoryNode; depth: number } + | { type: 'revert-all'; key: string }; + +const expandedKey = (groupId: string, path: string): string => `${groupId} ${path}`; + +export const ChangesPanel: React.FC = ({ + groups, + diffStats, + revertingPaths, + isRevertingAll = false, + onVisiblePathsChange, + onRevertAll, +}) => { + const { t } = useI18n(); + const scrollRef = React.useRef(null); + const gitChangesViewMode = useUIStore((state) => state.gitChangesViewMode); + const isTreeView = gitChangesViewMode === 'tree'; + + const visibleGroups = React.useMemo(() => groups.filter((group) => group.entries.length > 0), [groups]); + + const [collapsedGroups, setCollapsedGroups] = React.useState>(new Set()); + const [expandedDirectories, setExpandedDirectories] = React.useState>(new Set()); + const [revertAllOpen, setRevertAllOpen] = React.useState(false); + + const trees = React.useMemo( + () => visibleGroups.map((group) => buildChangesTree(group.entries)), + [visibleGroups] + ); + + // Auto-expand every top-level directory the first time it appears (mirrors prior + // ChangesSection behavior) while preserving user-collapsed nested directories. + const topLevelDirectoryKeys = React.useMemo(() => { + const keys: string[] = []; + visibleGroups.forEach((group, index) => { + Array.from(trees[index]?.children.values() ?? []).forEach((directory) => { + keys.push(expandedKey(group.id, directory.path)); + }); + }); + return keys; + }, [trees, visibleGroups]); + + React.useEffect(() => { + if (!isTreeView) { + return; + } + setExpandedDirectories((previous) => { + const next = new Set(); + const topLevel = new Set(topLevelDirectoryKeys); + previous.forEach((key) => { + const path = key.slice(key.indexOf(' ') + 1); + if (path.includes('/') || topLevel.has(key)) { + next.add(key); + } + }); + topLevelDirectoryKeys.forEach((key) => next.add(key)); + return next; + }); + }, [isTreeView, topLevelDirectoryKeys]); + + const rows = React.useMemo(() => { + const result: PanelRow[] = []; + + visibleGroups.forEach((group, groupIndex) => { + result.push({ type: 'header', key: `header:${group.id}`, groupIndex }); + + if (collapsedGroups.has(group.id)) { + return; + } + + if (isTreeView) { + const expandedForGroup = new Set(); + expandedDirectories.forEach((key) => { + if (key.startsWith(`${group.id} `)) { + expandedForGroup.add(key.slice(group.id.length + 1)); + } + }); + const treeRows = flattenChangesTree(trees[groupIndex], expandedForGroup); + treeRows.forEach((row: FlattenedTreeRow) => { + if (row.kind === 'file') { + result.push({ + type: 'file', + key: `${group.id}:${row.key}`, + groupIndex, + file: row.file, + depth: row.depth, + }); + } else { + result.push({ + type: 'directory', + key: `${group.id}:${row.key}`, + groupIndex, + directory: row.directory, + depth: row.depth, + }); + } + }); + return; + } + + group.entries.forEach((file) => { + result.push({ + type: 'file', + key: `${group.id}:file:${file.path}`, + groupIndex, + file, + depth: 0, + }); + }); + }); + + // Revert-all lives as the final in-flow row beneath the last file, so it + // scrolls with the list rather than sitting in a section header. + if (onRevertAll && visibleGroups.length > 0) { + result.push({ type: 'revert-all', key: 'revert-all' }); + } + + return result; + }, [collapsedGroups, expandedDirectories, isTreeView, onRevertAll, trees, visibleGroups]); + + const rowCount = rows.length; + const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD; + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => CHANGE_ROW_ESTIMATE_PX, + overscan: 12, + enabled: shouldVirtualize, + }); + + // Remeasure when the container transitions from display:none (hidden tab) back + // to visible layout, otherwise stale zero-height measurements render no rows. + 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]); + + const totalSize = rowVirtualizer.getTotalSize(); + const virtualRows = React.useMemo( + () => (shouldVirtualize && totalSize >= 0 ? rowVirtualizer.getVirtualItems() : []), + [shouldVirtualize, rowVirtualizer, totalSize] + ); + + React.useEffect(() => { + if (!onVisiblePathsChange) { + return; + } + + const collectFromRow = (row: PanelRow | undefined): string | null => + row && row.type === 'file' ? row.file.path : null; + + if (rowCount === 0) { + onVisiblePathsChange([]); + return; + } + + if (!shouldVirtualize) { + const paths: string[] = []; + for (const row of rows) { + if (row.type === 'file') { + paths.push(row.file.path); + if (paths.length >= VISIBLE_PREFETCH_LIMIT) break; + } + } + onVisiblePathsChange(paths); + return; + } + + onVisiblePathsChange( + virtualRows + .map((item) => collectFromRow(rows[item.index])) + .filter((value): value is string => Boolean(value)) + ); + }, [onVisiblePathsChange, rowCount, rows, shouldVirtualize, virtualRows]); + + const toggleGroupCollapsed = React.useCallback((groupId: string) => { + setCollapsedGroups((previous) => { + const next = new Set(previous); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }, []); + + const toggleDirectoryExpanded = React.useCallback((groupId: string, path: string) => { + setExpandedDirectories((previous) => { + const next = new Set(previous); + const key = expandedKey(groupId, path); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + // Every distinct changed path across groups (a partially-staged file appears in + // both, so dedupe). One revert-all discards all working-tree changes at once. + const allChangePaths = React.useMemo(() => { + const seen = new Set(); + 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)} +
+ ))} +
+ )} +
+ +
+ + { + if (!isRevertingAll && !open) setRevertAllOpen(false); + }} + > + + + {t('gitView.changes.revertAllDialogTitle')} + + {revertAllCount === 1 + ? t('gitView.changes.revertAllDescriptionSingle', { count: revertAllCount }) + : t('gitView.changes.revertAllDescriptionPlural', { count: revertAllCount })} + + + + + + + + + + ); +}; 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)} -
- ))} -
- )} -
- -
-
- - { if (!isRevertingAll) setConfirmRevertAllOpen(open); }}> - - - {t('gitView.changes.revertAllDialogTitle')} - - {totalCount === 1 - ? t('gitView.changes.revertAllDescriptionSingle', { count: totalCount }) - : t('gitView.changes.revertAllDescriptionPlural', { count: totalCount })} - - - - - - - - - - ); -}; 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'],