feat: Redesign git changes to split stage/unstaged files. (#1359)
* feat: Redesign git changes to split stage/unstaged files. Signed-off-by: Paolo Insogna <paolo@cowtech.it> * fixup Signed-off-by: Paolo Insogna <paolo@cowtech.it> * fixup Signed-off-by: Paolo Insogna <paolo@cowtech.it> * refactor: streamline git changes panel * fix: label staged and working diff tabs * fix: isolate staged and working diff files * fix: scope staged and working diff updates * fix: scope git row revert to working changes --------- Signed-off-by: Paolo Insogna <paolo@cowtech.it> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
9af0de0056
commit
e16097b05d
@@ -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/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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -97,11 +97,12 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = useUIStore.getState();
|
const store = useUIStore.getState();
|
||||||
|
const openStagedDiff = file.hasStagedChanges && !file.hasWorkingChanges;
|
||||||
if (!store.isMobile) {
|
if (!store.isMobile) {
|
||||||
store.openContextDiff(currentDirectory, file.relativePath);
|
store.openContextDiff(currentDirectory, file.relativePath, openStagedDiff);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
store.navigateToDiff(file.relativePath);
|
store.navigateToDiff(file.relativePath, openStagedDiff);
|
||||||
store.setRightSidebarOpen(false);
|
store.setRightSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface GitChangedFile {
|
|||||||
insertions: number;
|
insertions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
hasStagedChanges: boolean;
|
||||||
|
hasWorkingChanges: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChangedFileEntry = ChangedFile | GitChangedFile;
|
export type ChangedFileEntry = ChangedFile | GitChangedFile;
|
||||||
@@ -153,8 +155,12 @@ export const extractGitChangedFiles = (
|
|||||||
): GitChangedFile[] => {
|
): GitChangedFile[] => {
|
||||||
const result: GitChangedFile[] = [];
|
const result: GitChangedFile[] = [];
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const code = file.working_dir !== ' ' ? file.working_dir : file.index;
|
const indexStatus = file.index?.trim() ?? '';
|
||||||
if (code === '!' || code === ' ') continue;
|
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];
|
const stats = diffStats?.[file.path];
|
||||||
result.push({
|
result.push({
|
||||||
path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path,
|
path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path,
|
||||||
@@ -162,6 +168,8 @@ export const extractGitChangedFiles = (
|
|||||||
insertions: stats?.insertions ?? 0,
|
insertions: stats?.insertions ?? 0,
|
||||||
deletions: stats?.deletions ?? 0,
|
deletions: stats?.deletions ?? 0,
|
||||||
status: code,
|
status: code,
|
||||||
|
hasStagedChanges,
|
||||||
|
hasWorkingChanges,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ const getFileNameFromPath = (path: string | null): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTabLabel = (
|
const getTabLabel = (
|
||||||
tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null },
|
tab: { mode: ContextPanelMode; label: string | null; targetPath: string | null; stagedDiff?: boolean },
|
||||||
t: TranslateFn
|
t: TranslateFn
|
||||||
): string => {
|
): string => {
|
||||||
if (tab.label) {
|
if (tab.label) {
|
||||||
@@ -288,6 +288,10 @@ const getTabLabel = (
|
|||||||
return t('contextPanel.mode.preview');
|
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);
|
return getModeLabel(tab.mode, t);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1554,7 +1558,6 @@ export const ContextPanel: React.FC = () => {
|
|||||||
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
|
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
|
||||||
const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab);
|
const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab);
|
||||||
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
|
const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs);
|
||||||
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
|
|
||||||
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
||||||
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
||||||
const { themeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem();
|
const { themeMode, lightThemeId, darkThemeId, currentTheme } = useThemeSystem();
|
||||||
@@ -1690,10 +1693,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeTab.mode === 'diff' && activeTab.targetPath) {
|
}, [activeTab, directoryKey, setSelectedFilePath]);
|
||||||
setPendingDiffFile(activeTab.targetPath);
|
|
||||||
}
|
|
||||||
}, [activeTab, directoryKey, setPendingDiffFile, setSelectedFilePath]);
|
|
||||||
|
|
||||||
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
|
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
|
||||||
|
|
||||||
@@ -1797,7 +1797,18 @@ export const ContextPanel: React.FC = () => {
|
|||||||
}), [effectiveDirectory, t, tabs]);
|
}), [effectiveDirectory, t, tabs]);
|
||||||
|
|
||||||
const activeNonChatContent = activeTab?.mode === 'diff'
|
const activeNonChatContent = activeTab?.mode === 'diff'
|
||||||
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate showOpenInEditorAction />
|
? (
|
||||||
|
<DiffView
|
||||||
|
key={activeTab.id}
|
||||||
|
hideStackedFileSidebar
|
||||||
|
stackedDefaultCollapsedAll
|
||||||
|
hideFileSelector
|
||||||
|
pinSelectedFileHeaderToTopOnNavigate
|
||||||
|
showOpenInEditorAction
|
||||||
|
diffScope={activeTab.stagedDiff ? 'staged' : 'working'}
|
||||||
|
targetFilePath={activeTab.targetPath}
|
||||||
|
/>
|
||||||
|
)
|
||||||
: activeTab?.mode === 'context'
|
: activeTab?.mode === 'context'
|
||||||
? <ContextPanelContent />
|
? <ContextPanelContent />
|
||||||
: activeTab?.mode === 'plan'
|
: activeTab?.mode === 'plan'
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type FileEntry = GitStatus['files'][number] & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type DiffData = { original: string; modified: string; isBinary?: boolean };
|
type DiffData = { original: string; modified: string; isBinary?: boolean };
|
||||||
|
type DiffScope = 'all' | 'staged' | 'working';
|
||||||
|
|
||||||
const BinaryDiffPlaceholder = React.memo(() => {
|
const BinaryDiffPlaceholder = React.memo(() => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -118,6 +119,16 @@ const isNewStatusFile = (file: GitStatus['files'][number]): boolean => {
|
|||||||
return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?';
|
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 => {
|
const isAbsolutePath = (value: string): boolean => {
|
||||||
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
|
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
|
||||||
};
|
};
|
||||||
@@ -622,6 +633,8 @@ interface MultiFileDiffEntryProps {
|
|||||||
showOpenInEditorAction?: boolean;
|
showOpenInEditorAction?: boolean;
|
||||||
isOpeningInEditor?: boolean;
|
isOpeningInEditor?: boolean;
|
||||||
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
|
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
|
||||||
|
staged?: boolean;
|
||||||
|
stagedRevision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||||
@@ -639,6 +652,8 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
showOpenInEditorAction = false,
|
showOpenInEditorAction = false,
|
||||||
isOpeningInEditor = false,
|
isOpeningInEditor = false,
|
||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
|
staged = false,
|
||||||
|
stagedRevision = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { git } = useRuntimeAPIs();
|
const { git } = useRuntimeAPIs();
|
||||||
@@ -656,6 +671,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
|
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
|
||||||
|
const [stagedDiffData, setStagedDiffData] = React.useState<DiffData | null>(null);
|
||||||
const lastDiffRequestRef = React.useRef<string | null>(null);
|
const lastDiffRequestRef = React.useRef<string | null>(null);
|
||||||
const sectionRef = React.useRef<HTMLDivElement | null>(null);
|
const sectionRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -663,9 +679,10 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
const renderSideBySide = layout === 'side-by-side';
|
const renderSideBySide = layout === 'side-by-side';
|
||||||
|
|
||||||
const diffData = React.useMemo<DiffData | null>(() => {
|
const diffData = React.useMemo<DiffData | null>(() => {
|
||||||
|
if (staged) return stagedDiffData;
|
||||||
if (!cachedDiff) return null;
|
if (!cachedDiff) return null;
|
||||||
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary };
|
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary };
|
||||||
}, [cachedDiff]);
|
}, [cachedDiff, staged, stagedDiffData]);
|
||||||
|
|
||||||
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
|
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||||
sectionRef.current = node;
|
sectionRef.current = node;
|
||||||
@@ -716,6 +733,16 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
setHasBeenVisible(true);
|
setHasBeenVisible(true);
|
||||||
}, [expandRequestNonce, expandRequestPath, file.path]);
|
}, [expandRequestNonce, expandRequestPath, file.path]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!staged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStagedDiffData(null);
|
||||||
|
setDiffLoadError(null);
|
||||||
|
lastDiffRequestRef.current = null;
|
||||||
|
}, [staged, stagedRevision]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isExpanded || !hasBeenVisible) return;
|
if (!isExpanded || !hasBeenVisible) return;
|
||||||
if (!directory || diffData) {
|
if (!directory || diffData) {
|
||||||
@@ -724,7 +751,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestKey = `${directory}::${file.path}::${diffRetryNonce}`;
|
const requestKey = `${directory}::${file.path}::${staged ? `staged:${stagedRevision}` : 'unstaged'}::${diffRetryNonce}`;
|
||||||
if (lastDiffRequestRef.current === requestKey) {
|
if (lastDiffRequestRef.current === requestKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -733,7 +760,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
let cancelled = false;
|
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 timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||||
@@ -743,11 +770,16 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setDiff(directory, file.path, {
|
const nextDiff = {
|
||||||
original: response.original ?? '',
|
original: response.original ?? '',
|
||||||
modified: response.modified ?? '',
|
modified: response.modified ?? '',
|
||||||
isBinary: response.isBinary,
|
isBinary: response.isBinary,
|
||||||
});
|
};
|
||||||
|
if (staged) {
|
||||||
|
setStagedDiffData(nextDiff);
|
||||||
|
} else {
|
||||||
|
setDiff(directory, file.path, nextDiff);
|
||||||
|
}
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -763,7 +795,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
lastDiffRequestRef.current = null;
|
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(() => {
|
const handleToggle = React.useCallback(() => {
|
||||||
handleOpenChange(!isExpanded);
|
handleOpenChange(!isExpanded);
|
||||||
@@ -936,6 +968,8 @@ interface DiffViewProps {
|
|||||||
hideFileSelector?: boolean;
|
hideFileSelector?: boolean;
|
||||||
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
||||||
showOpenInEditorAction?: boolean;
|
showOpenInEditorAction?: boolean;
|
||||||
|
diffScope?: DiffScope;
|
||||||
|
targetFilePath?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DiffView: React.FC<DiffViewProps> = ({
|
export const DiffView: React.FC<DiffViewProps> = ({
|
||||||
@@ -944,6 +978,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
hideFileSelector = false,
|
hideFileSelector = false,
|
||||||
pinSelectedFileHeaderToTopOnNavigate = false,
|
pinSelectedFileHeaderToTopOnNavigate = false,
|
||||||
showOpenInEditorAction = false,
|
showOpenInEditorAction = false,
|
||||||
|
diffScope = 'all',
|
||||||
|
targetFilePath = null,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { git, files } = useRuntimeAPIs();
|
const { git, files } = useRuntimeAPIs();
|
||||||
@@ -957,8 +993,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
||||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||||
const setDiff = useGitStore((state) => state.setDiff);
|
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<string | null>(null);
|
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
|
||||||
|
const [selectedFileStaged, setSelectedFileStaged] = React.useState(false);
|
||||||
|
const [selectedStagedDiffData, setSelectedStagedDiffData] = React.useState<DiffData | null>(null);
|
||||||
const [stackedExpandTarget, setStackedExpandTarget] = React.useState<string | null>(null);
|
const [stackedExpandTarget, setStackedExpandTarget] = React.useState<string | null>(null);
|
||||||
const [stackedExpandRequestNonce, setStackedExpandRequestNonce] = React.useState(0);
|
const [stackedExpandRequestNonce, setStackedExpandRequestNonce] = React.useState(0);
|
||||||
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
|
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
|
||||||
@@ -967,6 +1009,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
const lastDiffRequestRef = React.useRef<string | null>(null);
|
const lastDiffRequestRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
|
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
|
||||||
|
const pendingDiffStaged = useUIStore((state) => state.pendingDiffStaged);
|
||||||
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
|
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
|
||||||
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
|
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
|
||||||
const diffFileLayout = useUIStore((state) => state.diffFileLayout);
|
const diffFileLayout = useUIStore((state) => state.diffFileLayout);
|
||||||
@@ -977,6 +1020,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
const setDiffViewMode = useUIStore((state) => state.setDiffViewMode);
|
const setDiffViewMode = useUIStore((state) => state.setDiffViewMode);
|
||||||
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
|
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
|
||||||
const diffWrapLines = diffWrapLinesStore;
|
const diffWrapLines = diffWrapLinesStore;
|
||||||
|
const forcedStaged = diffScope === 'staged' ? true : diffScope === 'working' ? false : null;
|
||||||
|
const activeDiffStaged = forcedStaged ?? selectedFileStaged;
|
||||||
|
|
||||||
const isStackedView = diffViewMode === 'stacked';
|
const isStackedView = diffViewMode === 'stacked';
|
||||||
const isMobileLayout = isMobile || screenWidth <= 768;
|
const isMobileLayout = isMobile || screenWidth <= 768;
|
||||||
@@ -1085,8 +1130,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
const changedFiles: FileEntry[] = React.useMemo(() => {
|
const changedFiles: FileEntry[] = React.useMemo(() => {
|
||||||
if (!status?.files) return [];
|
if (!status?.files) return [];
|
||||||
const diffStats = status.diffStats ?? {};
|
const diffStats = status.diffStats ?? {};
|
||||||
|
const includeFile = diffScope === 'staged'
|
||||||
|
? isStagedStatusFile
|
||||||
|
: diffScope === 'working'
|
||||||
|
? isWorkingStatusFile
|
||||||
|
: () => true;
|
||||||
|
|
||||||
return status.files
|
return status.files
|
||||||
|
.filter(includeFile)
|
||||||
.map((file) => ({
|
.map((file) => ({
|
||||||
...file,
|
...file,
|
||||||
insertions: diffStats[file.path]?.insertions ?? 0,
|
insertions: diffStats[file.path]?.insertions ?? 0,
|
||||||
@@ -1094,7 +1145,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
isNew: isNewStatusFile(file),
|
isNew: isNewStatusFile(file),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.path.localeCompare(b.path));
|
.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
}, [status]);
|
}, [diffScope, status]);
|
||||||
|
|
||||||
const selectedFileEntry = React.useMemo(() => {
|
const selectedFileEntry = React.useMemo(() => {
|
||||||
if (!selectedFile) return null;
|
if (!selectedFile) return null;
|
||||||
@@ -1149,8 +1200,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
|
|
||||||
// Handle pending diff file from external navigation
|
// Handle pending diff file from external navigation
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (diffScope !== 'all') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pendingDiffFile) {
|
if (pendingDiffFile) {
|
||||||
setSelectedFile(pendingDiffFile);
|
setSelectedFile(pendingDiffFile);
|
||||||
|
setSelectedFileStaged(pendingDiffStaged);
|
||||||
|
setSelectedStagedDiffData(null);
|
||||||
setPendingDiffFile(null);
|
setPendingDiffFile(null);
|
||||||
if (isStackedView) {
|
if (isStackedView) {
|
||||||
shouldPinAfterAlignRef.current = true;
|
shouldPinAfterAlignRef.current = true;
|
||||||
@@ -1159,7 +1216,39 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
setStackedExpandRequestNonce((nonce) => nonce + 1);
|
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)
|
// Auto-select first file (skip if we have a pending file to consume)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -1355,6 +1444,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
|
|
||||||
const handleSelectFile = React.useCallback((value: string) => {
|
const handleSelectFile = React.useCallback((value: string) => {
|
||||||
setSelectedFile(value);
|
setSelectedFile(value);
|
||||||
|
setSelectedFileStaged(false);
|
||||||
|
setSelectedStagedDiffData(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectFileAndScroll = React.useCallback((value: string) => {
|
const handleSelectFileAndScroll = React.useCallback((value: string) => {
|
||||||
@@ -1365,6 +1456,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
pendingScrollTargetRef.current = null;
|
pendingScrollTargetRef.current = null;
|
||||||
|
|
||||||
setSelectedFile(value);
|
setSelectedFile(value);
|
||||||
|
setSelectedFileStaged(false);
|
||||||
|
setSelectedStagedDiffData(null);
|
||||||
|
|
||||||
if (!isStackedView) {
|
if (!isStackedView) {
|
||||||
shouldPinAfterAlignRef.current = false;
|
shouldPinAfterAlignRef.current = false;
|
||||||
@@ -1405,14 +1498,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
const showFileSelector = !hideFileSelector && (!isStackedView || !showFileSidebar);
|
const showFileSelector = !hideFileSelector && (!isStackedView || !showFileSidebar);
|
||||||
|
|
||||||
const selectedCachedDiff = useGitStore(React.useCallback((state) => {
|
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;
|
return state.directories.get(effectiveDirectory)?.diffCache.get(selectedFile) ?? null;
|
||||||
}, [effectiveDirectory, selectedFile]));
|
}, [activeDiffStaged, effectiveDirectory, selectedFile]));
|
||||||
|
|
||||||
const selectedDiffData = React.useMemo<DiffData | null>(() => {
|
const selectedDiffData = React.useMemo<DiffData | null>(() => {
|
||||||
|
if (activeDiffStaged) return selectedStagedDiffData;
|
||||||
if (!selectedCachedDiff) return null;
|
if (!selectedCachedDiff) return null;
|
||||||
return { original: selectedCachedDiff.original, modified: selectedCachedDiff.modified, isBinary: selectedCachedDiff.isBinary };
|
return { original: selectedCachedDiff.original, modified: selectedCachedDiff.modified, isBinary: selectedCachedDiff.isBinary };
|
||||||
}, [selectedCachedDiff]);
|
}, [activeDiffStaged, selectedCachedDiff, selectedStagedDiffData]);
|
||||||
|
|
||||||
const [openingEditorFilePath, setOpeningEditorFilePath] = React.useState<string | null>(null);
|
const [openingEditorFilePath, setOpeningEditorFilePath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
@@ -1433,6 +1527,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
try {
|
try {
|
||||||
const patchResponse = await git.getGitDiff(effectiveDirectory, {
|
const patchResponse = await git.getGitDiff(effectiveDirectory, {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
|
staged: activeDiffStaged,
|
||||||
contextLines: 3,
|
contextLines: 3,
|
||||||
});
|
});
|
||||||
targetLine = getFirstVisibleModifiedLineFromPatch(patchResponse.diff);
|
targetLine = getFirstVisibleModifiedLineFromPatch(patchResponse.diff);
|
||||||
@@ -1443,13 +1538,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
|
|
||||||
let diffForNavigation = cachedDiffData;
|
let diffForNavigation = cachedDiffData;
|
||||||
if (targetLine === null || !diffForNavigation) {
|
if (targetLine === null || !diffForNavigation) {
|
||||||
const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath });
|
const response = await git.getGitFileDiff(effectiveDirectory, { path: filePath, staged: activeDiffStaged });
|
||||||
diffForNavigation = {
|
diffForNavigation = {
|
||||||
original: response.original ?? '',
|
original: response.original ?? '',
|
||||||
modified: response.modified ?? '',
|
modified: response.modified ?? '',
|
||||||
isBinary: response.isBinary,
|
isBinary: response.isBinary,
|
||||||
};
|
};
|
||||||
setDiff(effectiveDirectory, filePath, diffForNavigation);
|
if (!activeDiffStaged) {
|
||||||
|
setDiff(effectiveDirectory, filePath, diffForNavigation);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedTargetLine = targetLine ?? ((diffForNavigation.isBinary || isImageFile(filePath))
|
const resolvedTargetLine = targetLine ?? ((diffForNavigation.isBinary || isImageFile(filePath))
|
||||||
@@ -1472,7 +1569,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setOpeningEditorFilePath((current) => (current === filePath ? null : current));
|
setOpeningEditorFilePath((current) => (current === filePath ? null : current));
|
||||||
}
|
}
|
||||||
}, [effectiveDirectory, files, git, openContextFileAtLine, setDiff]);
|
}, [activeDiffStaged, effectiveDirectory, files, git, openContextFileAtLine, setDiff]);
|
||||||
|
|
||||||
const openSelectedFileInEditorAtChange = React.useCallback(async () => {
|
const openSelectedFileInEditorAtChange = React.useCallback(async () => {
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
@@ -1484,7 +1581,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
|
|
||||||
const isOpeningSelectedInEditor = Boolean(selectedFile && openingEditorFilePath === selectedFile);
|
const isOpeningSelectedInEditor = Boolean(selectedFile && openingEditorFilePath === selectedFile);
|
||||||
|
|
||||||
const hasCurrentDiff = !!selectedCachedDiff;
|
const hasCurrentDiff = activeDiffStaged ? !!selectedStagedDiffData : !!selectedCachedDiff;
|
||||||
const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff;
|
const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -1499,19 +1596,19 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCachedDiff) {
|
if (activeDiffStaged ? selectedStagedDiffData : selectedCachedDiff) {
|
||||||
lastDiffRequestRef.current = null;
|
lastDiffRequestRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestKey = `${effectiveDirectory}::${selectedFile}::${diffRetryNonce}`;
|
const requestKey = `${effectiveDirectory}::${selectedFile}::${activeDiffStaged ? `staged:${indexRevision}` : 'unstaged'}::${diffRetryNonce}`;
|
||||||
if (lastDiffRequestRef.current === requestKey) {
|
if (lastDiffRequestRef.current === requestKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastDiffRequestRef.current = requestKey;
|
lastDiffRequestRef.current = requestKey;
|
||||||
|
|
||||||
let cancelled = false;
|
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 timeoutMs = DIFF_REQUEST_TIMEOUT_MS;
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||||
@@ -1521,11 +1618,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
setDiff(effectiveDirectory, selectedFile, {
|
const nextDiff = {
|
||||||
original: response.original ?? '',
|
original: response.original ?? '',
|
||||||
modified: response.modified ?? '',
|
modified: response.modified ?? '',
|
||||||
isBinary: response.isBinary,
|
isBinary: response.isBinary,
|
||||||
});
|
};
|
||||||
|
if (activeDiffStaged) {
|
||||||
|
setSelectedStagedDiffData(nextDiff);
|
||||||
|
} else {
|
||||||
|
setDiff(effectiveDirectory, selectedFile, nextDiff);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -1540,7 +1642,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
lastDiffRequestRef.current = null;
|
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
|
// Render only the selected diff viewer to prevent memory bloat with many files
|
||||||
const renderSelectedDiffViewer = () => {
|
const renderSelectedDiffViewer = () => {
|
||||||
@@ -1562,6 +1664,12 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
if (!effectiveDirectory) return null;
|
if (!effectiveDirectory) return null;
|
||||||
|
|
||||||
const defaultExpandedCount = getStackedViewDefaultExpandedCount(changedFiles.length);
|
const defaultExpandedCount = getStackedViewDefaultExpandedCount(changedFiles.length);
|
||||||
|
const getFileStaged = (path: string) => {
|
||||||
|
if (forcedStaged !== null) {
|
||||||
|
return forcedStaged;
|
||||||
|
}
|
||||||
|
return selectedFileStaged && path === selectedFile;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 min-h-0 h-full gap-3 px-3 pb-3 pt-2">
|
<div className="flex flex-1 min-h-0 h-full gap-3 px-3 pb-3 pt-2">
|
||||||
@@ -1591,7 +1699,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{changedFiles.map((file, index) => (
|
{changedFiles.map((file, index) => (
|
||||||
<MultiFileDiffEntry
|
<MultiFileDiffEntry
|
||||||
key={file.path}
|
key={`${getFileStaged(file.path) ? 'staged' : 'unstaged'}:${file.path}`}
|
||||||
directory={effectiveDirectory}
|
directory={effectiveDirectory}
|
||||||
file={file}
|
file={file}
|
||||||
layout={getLayoutForFile(file)}
|
layout={getLayoutForFile(file)}
|
||||||
@@ -1608,6 +1716,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
onOpenInEditor={(filePath, diffData) => {
|
onOpenInEditor={(filePath, diffData) => {
|
||||||
void openFileInEditorAtChange(filePath, diffData);
|
void openFileInEditorAtChange(filePath, diffData);
|
||||||
}}
|
}}
|
||||||
|
staged={getFileStaged(file.path)}
|
||||||
|
stagedRevision={indexRevision}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
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 { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
@@ -47,7 +47,7 @@ import { IntegrateCommitsSection } from './git/IntegrateCommitsSection';
|
|||||||
|
|
||||||
import { GitHeader } from './git/GitHeader';
|
import { GitHeader } from './git/GitHeader';
|
||||||
import { StashesDialog } from './git/StashesDialog';
|
import { StashesDialog } from './git/StashesDialog';
|
||||||
import { ChangesSection } from './git/ChangesSection';
|
import { ChangesPanel, type ChangesGroupConfig } from './git/ChangesPanel';
|
||||||
import { CommitSection } from './git/CommitSection';
|
import { CommitSection } from './git/CommitSection';
|
||||||
import { GitEmptyState } from './git/GitEmptyState';
|
import { GitEmptyState } from './git/GitEmptyState';
|
||||||
import { HistorySection } from './git/HistorySection';
|
import { HistorySection } from './git/HistorySection';
|
||||||
@@ -56,6 +56,7 @@ import { ConflictDialog } from './git/ConflictDialog';
|
|||||||
import { StashDialog } from './git/StashDialog';
|
import { StashDialog } from './git/StashDialog';
|
||||||
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
|
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
|
||||||
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
|
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
|
||||||
|
import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue';
|
||||||
import type { GitRemote } from '@/lib/gitApi';
|
import type { GitRemote } from '@/lib/gitApi';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -74,13 +75,13 @@ type HistoryBranchDivider = {
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab';
|
const GIT_ACTION_TAB_STORAGE_KEY = 'oc.git.actionTab';
|
||||||
|
const GIT_RECONCILE_DELAY_MS = 15000;
|
||||||
|
|
||||||
const isActionTab = (value: unknown): value is ActionTab =>
|
const isActionTab = (value: unknown): value is ActionTab =>
|
||||||
value === 'commit' || value === 'branch' || value === 'pr';
|
value === 'commit' || value === 'branch' || value === 'pr';
|
||||||
|
|
||||||
type GitViewSnapshot = {
|
type GitViewSnapshot = {
|
||||||
directory?: string;
|
directory?: string;
|
||||||
selectedPaths: string[];
|
|
||||||
commitMessage: string;
|
commitMessage: string;
|
||||||
generatedHighlights: string[];
|
generatedHighlights: string[];
|
||||||
};
|
};
|
||||||
@@ -220,6 +221,17 @@ const gitViewSnapshots = new Map<string, GitViewSnapshot>();
|
|||||||
const normalizePath = (value?: string | null): string =>
|
const normalizePath = (value?: string | null): string =>
|
||||||
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
(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 = () => {
|
export const GitView: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { git } = useRuntimeAPIs();
|
const { git } = useRuntimeAPIs();
|
||||||
@@ -304,11 +316,116 @@ export const GitView: React.FC = () => {
|
|||||||
const fetchIdentity = useGitStore((state) => state.fetchIdentity);
|
const fetchIdentity = useGitStore((state) => state.fetchIdentity);
|
||||||
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
|
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
|
||||||
const setLogMaxCount = useGitStore((state) => state.setLogMaxCount);
|
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 isMobile = useUIStore((state) => state.isMobile);
|
||||||
const openContextDiff = useUIStore((state) => state.openContextDiff);
|
const openContextDiff = useUIStore((state) => state.openContextDiff);
|
||||||
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
||||||
const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen);
|
const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen);
|
||||||
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
|
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
|
||||||
|
const gitReconcileTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
const gitMutationFlushTimeoutRef = React.useRef<number | null>(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<GitIndexMutationQueue>(() => 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(() => {
|
React.useEffect(() => {
|
||||||
if (!currentDirectory) {
|
if (!currentDirectory) {
|
||||||
@@ -442,17 +559,15 @@ export const GitView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [selectedPaths, setSelectedPaths] = React.useState<Set<string>>(
|
|
||||||
() => new Set(initialSnapshot?.selectedPaths ?? [])
|
|
||||||
);
|
|
||||||
const [hasUserAdjustedSelection, setHasUserAdjustedSelection] = React.useState(false);
|
|
||||||
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
||||||
|
const [movingChangePaths, setMovingChangePaths] = React.useState<Set<string>>(new Set());
|
||||||
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
|
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
|
||||||
const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0);
|
const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0);
|
||||||
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
||||||
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
|
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
|
||||||
initialSnapshot?.generatedHighlights ?? []
|
initialSnapshot?.generatedHighlights ?? []
|
||||||
);
|
);
|
||||||
|
const hasPendingIndexMutation = movingChangePaths.size > 0 || gitIndexMutationQueue.size() > 0 || gitIndexMutationQueue.isRunning();
|
||||||
|
|
||||||
const scrollActionPanelToBottom = React.useCallback(() => {
|
const scrollActionPanelToBottom = React.useCallback(() => {
|
||||||
const scrollTarget = actionPanelScrollRef.current;
|
const scrollTarget = actionPanelScrollRef.current;
|
||||||
@@ -662,11 +777,10 @@ export const GitView: React.FC = () => {
|
|||||||
if (!currentDirectory) return;
|
if (!currentDirectory) return;
|
||||||
gitViewSnapshots.set(currentDirectory, {
|
gitViewSnapshots.set(currentDirectory, {
|
||||||
directory: currentDirectory,
|
directory: currentDirectory,
|
||||||
selectedPaths: Array.from(selectedPaths),
|
|
||||||
commitMessage,
|
commitMessage,
|
||||||
generatedHighlights,
|
generatedHighlights,
|
||||||
});
|
});
|
||||||
}, [commitMessage, currentDirectory, selectedPaths, generatedHighlights]);
|
}, [commitMessage, currentDirectory, generatedHighlights]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
loadProfiles();
|
loadProfiles();
|
||||||
@@ -844,6 +958,16 @@ export const GitView: React.FC = () => {
|
|||||||
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
|
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
const stagedChangeEntries = React.useMemo(
|
||||||
|
() => changeEntries.filter(isStagedStatusFile),
|
||||||
|
[changeEntries]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unstagedChangeEntries = React.useMemo(
|
||||||
|
() => changeEntries.filter(isUnstagedStatusFile),
|
||||||
|
[changeEntries]
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentDirectory || changeEntries.length === 0) {
|
if (!currentDirectory || changeEntries.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -860,7 +984,7 @@ export const GitView: React.FC = () => {
|
|||||||
orderedPaths.push(path);
|
orderedPaths.push(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
Array.from(selectedPaths).forEach(pushPath);
|
stagedChangeEntries.forEach((entry) => pushPath(entry.path));
|
||||||
visibleChangePaths.forEach(pushPath);
|
visibleChangePaths.forEach(pushPath);
|
||||||
changeEntries.slice(0, GIT_DIFF_PRIORITY_BASELINE_LIMIT).forEach((entry) => pushPath(entry.path));
|
changeEntries.slice(0, GIT_DIFF_PRIORITY_BASELINE_LIMIT).forEach((entry) => pushPath(entry.path));
|
||||||
|
|
||||||
@@ -875,30 +999,7 @@ export const GitView: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [changeEntries, currentDirectory, git, prefetchDiffs, selectedPaths, visibleChangePaths]);
|
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!status || changeEntries.length === 0) {
|
|
||||||
setSelectedPaths(new Set());
|
|
||||||
setHasUserAdjustedSelection(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedPaths((previous) => {
|
|
||||||
const next = new Set<string>();
|
|
||||||
const previousSet = previous ?? new Set<string>();
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
|
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
|
||||||
if (!currentDirectory) return;
|
if (!currentDirectory) return;
|
||||||
@@ -1027,9 +1128,9 @@ export const GitView: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesToCommit = Array.from(selectedPaths).sort();
|
const filesToCommit = stagedChangeEntries.map((file) => file.path).sort();
|
||||||
if (filesToCommit.length === 0) {
|
if (filesToCommit.length === 0) {
|
||||||
toast.error(t('gitView.toast.selectFileToCommit'));
|
toast.error(t('gitView.toast.stageFileToCommit'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,11 +1140,11 @@ export const GitView: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await git.createGitCommit(currentDirectory, commitMessage.trim(), {
|
await git.createGitCommit(currentDirectory, commitMessage.trim(), {
|
||||||
files: filesToCommit,
|
files: filesToCommit,
|
||||||
|
stageFiles: [],
|
||||||
});
|
});
|
||||||
|
bumpIndexRevision(currentDirectory);
|
||||||
toast.success(t('gitView.toast.commitCreated'));
|
toast.success(t('gitView.toast.commitCreated'));
|
||||||
setCommitMessage('');
|
setCommitMessage('');
|
||||||
setSelectedPaths(new Set());
|
|
||||||
setHasUserAdjustedSelection(false);
|
|
||||||
clearGeneratedHighlights();
|
clearGeneratedHighlights();
|
||||||
|
|
||||||
await refreshStatusAndBranches();
|
await refreshStatusAndBranches();
|
||||||
@@ -1120,19 +1221,20 @@ export const GitView: React.FC = () => {
|
|||||||
|
|
||||||
const handleGenerateCommitMessage = React.useCallback(async () => {
|
const handleGenerateCommitMessage = React.useCallback(async () => {
|
||||||
if (!currentDirectory) return;
|
if (!currentDirectory) return;
|
||||||
if (selectedPaths.size === 0) {
|
const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort();
|
||||||
toast.error(t('gitView.toast.selectFileToDescribe'));
|
if (selectedFilePaths.length === 0) {
|
||||||
|
toast.error(t('gitView.toast.stageFileToDescribe'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('[git-generation][browser] generate button clicked', {
|
console.error('[git-generation][browser] generate button clicked', {
|
||||||
directory: currentDirectory,
|
directory: currentDirectory,
|
||||||
selectedFiles: selectedPaths.size,
|
selectedFiles: selectedFilePaths.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsGeneratingMessage(true);
|
setIsGeneratingMessage(true);
|
||||||
try {
|
try {
|
||||||
const { message } = await generateSessionCommitMessage(currentDirectory, Array.from(selectedPaths));
|
const { message } = await generateSessionCommitMessage(currentDirectory, selectedFilePaths);
|
||||||
const subject = message.subject?.trim() ?? '';
|
const subject = message.subject?.trim() ?? '';
|
||||||
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
|
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
|
||||||
|
|
||||||
@@ -1163,7 +1265,7 @@ export const GitView: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsGeneratingMessage(false);
|
setIsGeneratingMessage(false);
|
||||||
}
|
}
|
||||||
}, [currentDirectory, selectedPaths, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]);
|
}, [currentDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]);
|
||||||
|
|
||||||
const formatBlockingReason = (reason: ReturnType<typeof getMutationBlockingReasons>[number]): string => {
|
const formatBlockingReason = (reason: ReturnType<typeof getMutationBlockingReasons>[number]): string => {
|
||||||
if (reason.reason === 'attention') {
|
if (reason.reason === 'attention') {
|
||||||
@@ -1477,7 +1579,7 @@ export const GitView: React.FC = () => {
|
|||||||
return globalIdentity ?? null;
|
return globalIdentity ?? null;
|
||||||
}, [currentIdentity, profiles, globalIdentity]);
|
}, [currentIdentity, profiles, globalIdentity]);
|
||||||
|
|
||||||
const selectedCount = selectedPaths.size;
|
const stagedCount = stagedChangeEntries.length;
|
||||||
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
||||||
const currentBranch = status?.current ?? null;
|
const currentBranch = status?.current ?? null;
|
||||||
const canShowIntegrateCommitsSection = Boolean(
|
const canShowIntegrateCommitsSection = Boolean(
|
||||||
@@ -1571,29 +1673,25 @@ export const GitView: React.FC = () => {
|
|||||||
}, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]);
|
}, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]);
|
||||||
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
|
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
|
||||||
|
|
||||||
const toggleFileSelection = (path: string) => {
|
const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => {
|
||||||
setSelectedPaths((previous) => {
|
if (!currentDirectory || paths.length === 0) return;
|
||||||
|
const uniquePaths = Array.from(new Set(paths));
|
||||||
|
setMovingChangePaths((previous) => {
|
||||||
const next = new Set(previous);
|
const next = new Set(previous);
|
||||||
if (next.has(path)) {
|
uniquePaths.forEach((path) => next.add(path));
|
||||||
next.delete(path);
|
|
||||||
} else {
|
|
||||||
next.add(path);
|
|
||||||
}
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setHasUserAdjustedSelection(true);
|
const previousStatus = moveStatusPathsOptimistically(currentDirectory, uniquePaths, direction);
|
||||||
};
|
|
||||||
|
|
||||||
const selectAll = () => {
|
gitIndexMutationQueue.enqueue({
|
||||||
const next = new Set(changeEntries.map((file) => file.path));
|
directory: currentDirectory,
|
||||||
setSelectedPaths(next);
|
direction,
|
||||||
setHasUserAdjustedSelection(true);
|
paths: new Set(uniquePaths),
|
||||||
};
|
rollback: () => restoreStatus(currentDirectory, previousStatus),
|
||||||
|
});
|
||||||
|
|
||||||
const clearSelection = () => {
|
scheduleGitMutationFlush();
|
||||||
setSelectedPaths(new Set());
|
}, [currentDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]);
|
||||||
setHasUserAdjustedSelection(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRevertFile = React.useCallback(
|
const handleRevertFile = React.useCallback(
|
||||||
async (filePath: string) => {
|
async (filePath: string) => {
|
||||||
@@ -1606,7 +1704,7 @@ export const GitView: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await git.revertGitFile(currentDirectory, filePath);
|
await git.revertGitFile(currentDirectory, filePath, { scope: 'working' });
|
||||||
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
|
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
|
||||||
await refreshStatusAndBranches(false);
|
await refreshStatusAndBranches(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1630,6 +1728,8 @@ export const GitView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uniquePaths = Array.from(new Set(paths));
|
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);
|
setIsRevertingAll(true);
|
||||||
setRevertingPaths((previous) => {
|
setRevertingPaths((previous) => {
|
||||||
const next = new Set(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);
|
await refreshStatusAndBranches(false);
|
||||||
|
|
||||||
if (failed.length === 0) {
|
if (failed.length === 0) {
|
||||||
@@ -1678,9 +1782,67 @@ export const GitView: React.FC = () => {
|
|||||||
setIsRevertingAll(false);
|
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<ChangesGroupConfig[]>(() => {
|
||||||
|
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[]) => {
|
const handleInsertHighlights = React.useCallback((sourceHighlights: string[]) => {
|
||||||
if (sourceHighlights.length === 0) return;
|
if (sourceHighlights.length === 0) return;
|
||||||
const normalizedHighlights = sourceHighlights
|
const normalizedHighlights = sourceHighlights
|
||||||
@@ -2007,6 +2169,7 @@ export const GitView: React.FC = () => {
|
|||||||
const currentBranch = status?.current;
|
const currentBranch = status?.current;
|
||||||
const operation = stashDialogOperation;
|
const operation = stashDialogOperation;
|
||||||
const branch = stashDialogBranch;
|
const branch = stashDialogBranch;
|
||||||
|
const hadStagedChanges = (status?.files ?? []).some(isStagedStatusFile);
|
||||||
|
|
||||||
// Stash changes
|
// Stash changes
|
||||||
try {
|
try {
|
||||||
@@ -2014,6 +2177,9 @@ export const GitView: React.FC = () => {
|
|||||||
message: `Auto-stash before ${operation} with ${branch}`,
|
message: `Auto-stash before ${operation} with ${branch}`,
|
||||||
includeUntracked: true,
|
includeUntracked: true,
|
||||||
});
|
});
|
||||||
|
if (hadStagedChanges) {
|
||||||
|
bumpIndexRevision(currentDirectory);
|
||||||
|
}
|
||||||
} catch (stashErr) {
|
} catch (stashErr) {
|
||||||
const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes';
|
const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes';
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
@@ -2053,6 +2219,7 @@ export const GitView: React.FC = () => {
|
|||||||
if (restoreAfter && operationSucceeded) {
|
if (restoreAfter && operationSucceeded) {
|
||||||
try {
|
try {
|
||||||
await git.stashPop(currentDirectory);
|
await git.stashPop(currentDirectory);
|
||||||
|
bumpIndexRevision(currentDirectory);
|
||||||
toast.success(t('gitView.toast.stashedRestored'));
|
toast.success(t('gitView.toast.stashedRestored'));
|
||||||
} catch (popErr) {
|
} catch (popErr) {
|
||||||
const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed');
|
const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed');
|
||||||
@@ -2069,6 +2236,7 @@ export const GitView: React.FC = () => {
|
|||||||
if (restoreAfter) {
|
if (restoreAfter) {
|
||||||
try {
|
try {
|
||||||
await git.stashPop(currentDirectory);
|
await git.stashPop(currentDirectory);
|
||||||
|
bumpIndexRevision(currentDirectory);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore stash pop errors in this case
|
// Ignore stash pop errors in this case
|
||||||
}
|
}
|
||||||
@@ -2076,7 +2244,7 @@ export const GitView: React.FC = () => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t]
|
[bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentDirectory) {
|
if (!currentDirectory) {
|
||||||
@@ -2155,6 +2323,7 @@ export const GitView: React.FC = () => {
|
|||||||
isApplyingIdentity={isSettingIdentity}
|
isApplyingIdentity={isSettingIdentity}
|
||||||
isWorktreeMode={!!worktreeMetadata}
|
isWorktreeMode={!!worktreeMetadata}
|
||||||
onOpenHistory={() => setIsHistoryDialogOpen(true)}
|
onOpenHistory={() => setIsHistoryDialogOpen(true)}
|
||||||
|
onOpenStashes={openStashes}
|
||||||
actionTabItems={actionTabItems}
|
actionTabItems={actionTabItems}
|
||||||
activeActionTab={actionTab}
|
activeActionTab={actionTab}
|
||||||
onSelectActionTab={(tabID) => setActionTab(tabID as ActionTab)}
|
onSelectActionTab={(tabID) => setActionTab(tabID as ActionTab)}
|
||||||
@@ -2188,37 +2357,22 @@ export const GitView: React.FC = () => {
|
|||||||
preventOverscroll
|
preventOverscroll
|
||||||
>
|
>
|
||||||
{actionTab === 'commit' ? (
|
{actionTab === 'commit' ? (
|
||||||
<div className="space-y-4">
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||||
{(changeEntries?.length ?? 0) > 0 ? (
|
{(changeEntries?.length ?? 0) > 0 ? (
|
||||||
<>
|
<>
|
||||||
<ChangesSection
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
maxListHeightClassName="max-h-[40vh]"
|
<ChangesPanel
|
||||||
changeEntries={changeEntries}
|
groups={changeGroups}
|
||||||
onVisiblePathsChange={setVisibleChangePaths}
|
diffStats={status?.diffStats}
|
||||||
selectedPaths={selectedPaths}
|
revertingPaths={revertingPaths}
|
||||||
diffStats={status?.diffStats}
|
isRevertingAll={isRevertingAll}
|
||||||
revertingPaths={revertingPaths}
|
onVisiblePathsChange={setVisibleChangePaths}
|
||||||
onToggleFile={toggleFileSelection}
|
onRevertAll={handleRevertAll}
|
||||||
onSelectAll={selectAll}
|
/>
|
||||||
onClearSelection={clearSelection}
|
</div>
|
||||||
onRevertAll={handleRevertAll}
|
|
||||||
onViewDiff={(path) => {
|
|
||||||
if (currentDirectory && !isMobile) {
|
|
||||||
openContextDiff(currentDirectory, path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigateToDiff(path);
|
|
||||||
if (isMobile) {
|
|
||||||
setRightSidebarOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onRevertFile={handleRevertFile}
|
|
||||||
isRevertingAll={isRevertingAll}
|
|
||||||
onOpenStashes={() => setIsStashesDialogOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CommitSection
|
<CommitSection
|
||||||
selectedCount={selectedCount}
|
stagedCount={stagedCount}
|
||||||
commitMessage={commitMessage}
|
commitMessage={commitMessage}
|
||||||
onCommitMessageChange={setCommitMessage}
|
onCommitMessageChange={setCommitMessage}
|
||||||
generatedHighlights={generatedHighlights}
|
generatedHighlights={generatedHighlights}
|
||||||
@@ -2228,6 +2382,7 @@ export const GitView: React.FC = () => {
|
|||||||
onCommit={() => handleCommit({ pushAfter: false })}
|
onCommit={() => handleCommit({ pushAfter: false })}
|
||||||
onCommitAndPush={() => handleCommit({ pushAfter: true })}
|
onCommitAndPush={() => handleCommit({ pushAfter: true })}
|
||||||
commitAction={commitAction}
|
commitAction={commitAction}
|
||||||
|
hasPendingIndexMutation={hasPendingIndexMutation}
|
||||||
gitmojiEnabled={settingsGitmojiEnabled}
|
gitmojiEnabled={settingsGitmojiEnabled}
|
||||||
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -2339,8 +2494,12 @@ export const GitView: React.FC = () => {
|
|||||||
onOpenChange={setIsStashesDialogOpen}
|
onOpenChange={setIsStashesDialogOpen}
|
||||||
directory={currentDirectory}
|
directory={currentDirectory}
|
||||||
hasUncommittedChanges={(status?.files?.length ?? 0) > 0}
|
hasUncommittedChanges={(status?.files?.length ?? 0) > 0}
|
||||||
|
hasStagedChanges={stagedChangeEntries.length > 0}
|
||||||
uncommittedFileCount={status?.files?.length ?? 0}
|
uncommittedFileCount={status?.files?.length ?? 0}
|
||||||
onChanged={async () => {
|
onChanged={async (change) => {
|
||||||
|
if (currentDirectory && change?.affectsIndex) {
|
||||||
|
bumpIndexRevision(currentDirectory);
|
||||||
|
}
|
||||||
await refreshStatusAndBranches(false);
|
await refreshStatusAndBranches(false);
|
||||||
await refreshLog();
|
await refreshLog();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import type { GitStatus } from '@/lib/api/types';
|
import type { GitStatus } from '@/lib/api/types';
|
||||||
@@ -40,26 +39,33 @@ function describeChange(file: GitStatus['files'][number]): ChangeDescriptor {
|
|||||||
|
|
||||||
interface ChangeRowProps {
|
interface ChangeRowProps {
|
||||||
file: GitStatus['files'][number];
|
file: GitStatus['files'][number];
|
||||||
checked: boolean;
|
actionLabel: string;
|
||||||
onToggle: () => void;
|
actionSymbol: '+' | '-';
|
||||||
|
onAction: () => void;
|
||||||
onViewDiff: () => void;
|
onViewDiff: () => void;
|
||||||
onRevert: () => void;
|
onRevert: () => void;
|
||||||
isReverting: boolean;
|
isReverting: boolean;
|
||||||
stats?: { insertions: number; deletions: number };
|
stats?: { insertions: number; deletions: number };
|
||||||
rowPaddingClassName?: string;
|
rowPaddingClassName?: string;
|
||||||
indentPx?: number;
|
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<ChangeRowProps>(function ChangeRow({
|
export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||||
file,
|
file,
|
||||||
checked,
|
actionLabel,
|
||||||
onToggle,
|
actionSymbol,
|
||||||
|
onAction,
|
||||||
onViewDiff,
|
onViewDiff,
|
||||||
onRevert,
|
onRevert,
|
||||||
isReverting,
|
isReverting,
|
||||||
stats,
|
stats,
|
||||||
rowPaddingClassName,
|
rowPaddingClassName,
|
||||||
indentPx = 0,
|
indentPx = 0,
|
||||||
|
actionAtStart = false,
|
||||||
|
showRevert = true,
|
||||||
}) {
|
}) {
|
||||||
const descriptor = useMemo(() => describeChange(file), [file]);
|
const descriptor = useMemo(() => describeChange(file), [file]);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -71,13 +77,22 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
|||||||
(event: React.KeyboardEvent) => {
|
(event: React.KeyboardEvent) => {
|
||||||
if (event.key === ' ') {
|
if (event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onToggle();
|
onAction();
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onViewDiff();
|
onViewDiff();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onToggle, onViewDiff]
|
[onAction, onViewDiff]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleActionClick = useCallback(
|
||||||
|
(event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onAction();
|
||||||
|
},
|
||||||
|
[onAction]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRevertClick = useCallback(
|
const handleRevertClick = useCallback(
|
||||||
@@ -89,6 +104,18 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
|||||||
[onRevert]
|
[onRevert]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const actionButton = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleActionClick}
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center rounded typography-micro font-semibold text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||||
|
aria-label={actionLabel}
|
||||||
|
title={actionLabel}
|
||||||
|
>
|
||||||
|
{actionSymbol}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group flex items-center gap-2 py-1.5 hover:bg-sidebar/40 cursor-pointer ${rowPaddingClassName ?? 'px-3'}`}
|
className={`group flex items-center gap-2 py-1.5 hover:bg-sidebar/40 cursor-pointer ${rowPaddingClassName ?? 'px-3'}`}
|
||||||
@@ -98,14 +125,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex size-5 shrink-0 items-center justify-center" onClick={(e) => { e.stopPropagation(); }}>
|
{actionAtStart ? actionButton : null}
|
||||||
<Checkbox
|
|
||||||
size="sm"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => onToggle()}
|
|
||||||
ariaLabel={t('gitView.changes.selectFileAria', { path: file.path })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span
|
<span
|
||||||
className="typography-micro font-semibold w-4 text-center uppercase"
|
className="typography-micro font-semibold w-4 text-center uppercase"
|
||||||
style={{ color: descriptor.color }}
|
style={{ color: descriptor.color }}
|
||||||
@@ -147,24 +167,27 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
|||||||
<span className="text-muted-foreground mx-0.5">/</span>
|
<span className="text-muted-foreground mx-0.5">/</span>
|
||||||
<span style={{ color: 'var(--status-error)' }}>-{deletions}</span>
|
<span style={{ color: 'var(--status-error)' }}>-{deletions}</span>
|
||||||
</span>
|
</span>
|
||||||
<Tooltip>
|
{showRevert ? (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<button
|
||||||
onClick={handleRevertClick}
|
type="button"
|
||||||
disabled={isReverting}
|
onClick={handleRevertClick}
|
||||||
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
disabled={isReverting}
|
||||||
aria-label={t('gitView.changes.revertFileAria', { path: file.path })}
|
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
aria-label={t('gitView.changes.revertFileAria', { path: file.path })}
|
||||||
{isReverting ? (
|
>
|
||||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
{isReverting ? (
|
||||||
) : (
|
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||||
<Icon name="arrow-go-back" className="size-3.5" />
|
) : (
|
||||||
)}
|
<Icon name="arrow-go-back" className="size-3.5" />
|
||||||
</button>
|
)}
|
||||||
</TooltipTrigger>
|
</button>
|
||||||
<TooltipContent sideOffset={8}>{t('gitView.changes.revertFileTooltip')}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent sideOffset={8}>{t('gitView.changes.revertFileTooltip')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{actionAtStart ? null : actionButton}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string, { insertions: number; deletions: number }> | undefined;
|
||||||
|
revertingPaths: Set<string>;
|
||||||
|
isRevertingAll?: boolean;
|
||||||
|
onVisiblePathsChange?: (paths: string[]) => void;
|
||||||
|
/** Reverts every changed path across all groups; rendered once for the panel. */
|
||||||
|
onRevertAll?: (paths: string[]) => Promise<void> | 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<ChangesPanelProps> = ({
|
||||||
|
groups,
|
||||||
|
diffStats,
|
||||||
|
revertingPaths,
|
||||||
|
isRevertingAll = false,
|
||||||
|
onVisiblePathsChange,
|
||||||
|
onRevertAll,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const scrollRef = React.useRef<HTMLDivElement | null>(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<Set<string>>(new Set());
|
||||||
|
const [expandedDirectories, setExpandedDirectories] = React.useState<Set<string>>(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<string>();
|
||||||
|
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<PanelRow[]>(() => {
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'sticky top-0 z-10 flex items-center gap-2 bg-sidebar py-2',
|
||||||
|
ROW_PADDING_CLASSNAME,
|
||||||
|
!isFirst && 'mt-1 border-t border-border/40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => group.onActionAll(group.entries.map((entry) => entry.path))}
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center rounded typography-micro font-semibold text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||||
|
aria-label={group.actionAllLabel}
|
||||||
|
title={group.actionAllLabel}
|
||||||
|
>
|
||||||
|
{group.actionSymbol}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleGroupCollapsed(group.id)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
>
|
||||||
|
<h3 className="truncate typography-ui-header font-semibold text-foreground">{group.title}</h3>
|
||||||
|
<span className="typography-meta text-muted-foreground">{count}</span>
|
||||||
|
<Icon
|
||||||
|
name="arrow-down-s"
|
||||||
|
className={cn(
|
||||||
|
'size-3.5 shrink-0 text-muted-foreground transition-transform',
|
||||||
|
collapsed && '-rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[collapsedGroups, toggleGroupCollapsed]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDirectory = React.useCallback(
|
||||||
|
(group: ChangesGroupConfig, directory: ChangesTreeDirectoryNode, depth: number) => {
|
||||||
|
const isExpanded = expandedDirectories.has(expandedKey(group.id, directory.path));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('group flex items-center gap-2 py-1.5 hover:bg-sidebar/40', ROW_PADDING_CLASSNAME)}
|
||||||
|
style={{ paddingLeft: `${depth * TREE_INDENT_PX}px` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDirectoryExpanded(group.id, directory.path)}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
|
aria-label={
|
||||||
|
isExpanded
|
||||||
|
? t('gitView.changes.collapseDirectoryAria', { path: directory.path })
|
||||||
|
: t('gitView.changes.expandDirectoryAria', { path: directory.path })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<Icon name="folder-open-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
||||||
|
) : (
|
||||||
|
<Icon name="folder-3-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground" title={directory.path}>
|
||||||
|
{directory.name}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 typography-micro text-muted-foreground">{directory.files.length}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => group.onActionAll(directory.files.map((file) => file.path))}
|
||||||
|
className="flex size-5 shrink-0 items-center justify-center rounded typography-micro font-semibold text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||||
|
aria-label={t(
|
||||||
|
group.actionSymbol === '+' ? 'gitView.changes.stageDirectoryAria' : 'gitView.changes.unstageDirectoryAria',
|
||||||
|
{ path: directory.path }
|
||||||
|
)}
|
||||||
|
title={t(
|
||||||
|
group.actionSymbol === '+' ? 'gitView.changes.stageDirectoryAria' : 'gitView.changes.unstageDirectoryAria',
|
||||||
|
{ path: directory.path }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.actionSymbol}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[expandedDirectories, t, toggleDirectoryExpanded]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRow = React.useCallback(
|
||||||
|
(row: PanelRow, isFirstRow: boolean) => {
|
||||||
|
if (row.type === 'revert-all') {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex justify-end py-2', ROW_PADDING_CLASSNAME)}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRevertAllOpen(true)}
|
||||||
|
disabled={isRevertingAll}
|
||||||
|
className="gap-1.5 text-[var(--status-error)] hover:bg-[var(--status-error)]/10 hover:text-[var(--status-error)]"
|
||||||
|
>
|
||||||
|
<Icon name="arrow-go-back" className="size-3.5" />
|
||||||
|
{t('gitView.changes.revertAll')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ChangeRow
|
||||||
|
file={file}
|
||||||
|
actionLabel={group.getActionLabel(file.path)}
|
||||||
|
actionSymbol={group.actionSymbol}
|
||||||
|
onAction={() => 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 (
|
||||||
|
<>
|
||||||
|
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||||
|
<ScrollShadow
|
||||||
|
ref={scrollRef}
|
||||||
|
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
|
>
|
||||||
|
{shouldVirtualize ? (
|
||||||
|
<div className="relative w-full" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
|
||||||
|
{virtualRows.map((item) => {
|
||||||
|
const row = rows[item.index];
|
||||||
|
if (!row) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.key}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
data-index={item.index}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 top-0 w-full',
|
||||||
|
showDivider(item.index) &&
|
||||||
|
'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(${item.start}px)` }}
|
||||||
|
>
|
||||||
|
{renderRow(row, item.index === 0)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={row.key}
|
||||||
|
className={cn(
|
||||||
|
'relative',
|
||||||
|
showDivider(index) &&
|
||||||
|
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderRow(row, index === 0)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollShadow>
|
||||||
|
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={revertAllOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!isRevertingAll && !open) setRevertAllOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('gitView.changes.revertAllDialogTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{revertAllCount === 1
|
||||||
|
? t('gitView.changes.revertAllDescriptionSingle', { count: revertAllCount })
|
||||||
|
: t('gitView.changes.revertAllDescriptionPlural', { count: revertAllCount })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setRevertAllOpen(false)} disabled={isRevertingAll}>
|
||||||
|
{t('gitView.common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleConfirmRevertAll()}
|
||||||
|
disabled={isRevertingAll}
|
||||||
|
>
|
||||||
|
{isRevertingAll ? t('gitView.changes.reverting') : t('gitView.changes.revertAll')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<string>;
|
|
||||||
diffStats: Record<string, { insertions: number; deletions: number }> | undefined;
|
|
||||||
revertingPaths: Set<string>;
|
|
||||||
onToggleFile: (path: string) => void;
|
|
||||||
onSelectAll: () => void;
|
|
||||||
onClearSelection: () => void;
|
|
||||||
onRevertAll?: (paths: string[]) => Promise<void> | 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<string, ChangesTreeDirectoryNode>;
|
|
||||||
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<string>,
|
|
||||||
): 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<string>
|
|
||||||
): '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<ChangesSectionProps> = ({
|
|
||||||
changeEntries,
|
|
||||||
selectedPaths,
|
|
||||||
diffStats,
|
|
||||||
revertingPaths,
|
|
||||||
onToggleFile,
|
|
||||||
onSelectAll,
|
|
||||||
onClearSelection,
|
|
||||||
onRevertAll,
|
|
||||||
onViewDiff,
|
|
||||||
onRevertFile,
|
|
||||||
isRevertingAll = false,
|
|
||||||
maxListHeightClassName,
|
|
||||||
onVisiblePathsChange,
|
|
||||||
onOpenStashes,
|
|
||||||
}) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(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<Set<string>>(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<string>();
|
|
||||||
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 (
|
|
||||||
<ChangeRow
|
|
||||||
file={file}
|
|
||||||
checked={selectedPaths.has(file.path)}
|
|
||||||
stats={diffStats?.[file.path]}
|
|
||||||
onToggle={() => 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 (
|
|
||||||
<ChangeRow
|
|
||||||
file={file}
|
|
||||||
checked={selectedPaths.has(file.path)}
|
|
||||||
stats={diffStats?.[file.path]}
|
|
||||||
onToggle={() => 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 (
|
|
||||||
<div
|
|
||||||
className={cn('group flex items-center gap-2 py-1.5 hover:bg-sidebar/40', rowPaddingClassName)}
|
|
||||||
style={{ paddingLeft: `${row.depth * TREE_INDENT_PX}px` }}
|
|
||||||
>
|
|
||||||
<div className="flex size-5 shrink-0 items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
size="sm"
|
|
||||||
checked={selectionState === 'all'}
|
|
||||||
indeterminate={selectionState === 'partial'}
|
|
||||||
onChange={() => toggleDirectorySelection(directory)}
|
|
||||||
ariaLabel={t('gitView.changes.toggleDirectorySelectionAria', { path: directory.path })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleDirectoryExpanded(directory.path)}
|
|
||||||
className="flex min-w-0 flex-1 items-center gap-2 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
||||||
aria-label={isExpanded
|
|
||||||
? t('gitView.changes.collapseDirectoryAria', { path: directory.path })
|
|
||||||
: t('gitView.changes.expandDirectoryAria', { path: directory.path })}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<Icon name="folder-open-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
|
||||||
) : (
|
|
||||||
<Icon name="folder-3-fill" className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground" title={directory.path}>
|
|
||||||
{directory.name}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto shrink-0 typography-micro text-muted-foreground">{directory.files.length}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<section className={containerClassName}>
|
|
||||||
<header className={headerClassName}>
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('gitView.changes.title')}</h3>
|
|
||||||
{totalCount > 0 ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-6 items-center gap-1 rounded px-1.5',
|
|
||||||
isRevertingAll && 'cursor-not-allowed opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
size="sm"
|
|
||||||
checked={hasAnySelected}
|
|
||||||
indeterminate={isPartiallySelected}
|
|
||||||
disabled={isRevertingAll}
|
|
||||||
onChange={() => (areAllSelected ? onClearSelection() : onSelectAll())}
|
|
||||||
ariaLabel={areAllSelected ? t('gitView.changes.clearSelectionAria') : t('gitView.changes.selectAllAria')}
|
|
||||||
/>
|
|
||||||
<span className="typography-meta text-muted-foreground">{selectedCount}/{totalCount}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{onOpenStashes ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
className="h-6 px-1.5"
|
|
||||||
onClick={onOpenStashes}
|
|
||||||
aria-label={t('gitView.stashes.title')}
|
|
||||||
title={t('gitView.stashes.title')}
|
|
||||||
>
|
|
||||||
<Icon name="archive-stack" className="size-4" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 pr-1">
|
|
||||||
{totalCount > 0 && onRevertAll ? (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => setConfirmRevertAllOpen(true)}
|
|
||||||
disabled={isRevertingAll}
|
|
||||||
>
|
|
||||||
{t('gitView.changes.revertAll')}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className={cn('relative flex flex-col min-h-0 w-full overflow-hidden', scrollOuterClassName)}>
|
|
||||||
<ScrollShadow
|
|
||||||
ref={scrollRef}
|
|
||||||
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
|
|
||||||
>
|
|
||||||
{shouldVirtualize ? (
|
|
||||||
<div
|
|
||||||
className="relative w-full"
|
|
||||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
|
||||||
>
|
|
||||||
{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 (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
ref={rowVirtualizer.measureElement}
|
|
||||||
data-index={row.index}
|
|
||||||
className={cn(
|
|
||||||
'absolute left-0 top-0 w-full',
|
|
||||||
row.index > 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)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
|
|
||||||
{rowItems.map((item, index) => (
|
|
||||||
<div
|
|
||||||
key={isTreeView ? (item as FlattenedTreeRow).key : `file:${(item as GitStatus['files'][number]).path}`}
|
|
||||||
className={cn(
|
|
||||||
'relative',
|
|
||||||
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)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollShadow>
|
|
||||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<Dialog open={confirmRevertAllOpen} onOpenChange={(open) => { if (!isRevertingAll) setConfirmRevertAllOpen(open); }}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t('gitView.changes.revertAllDialogTitle')}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{totalCount === 1
|
|
||||||
? t('gitView.changes.revertAllDescriptionSingle', { count: totalCount })
|
|
||||||
: t('gitView.changes.revertAllDescriptionPlural', { count: totalCount })}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setConfirmRevertAllOpen(false)} disabled={isRevertingAll}>
|
|
||||||
{t('gitView.common.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" size="sm" onClick={() => void handleConfirmRevertAll()} disabled={isRevertingAll}>
|
|
||||||
{isRevertingAll ? t('gitView.changes.reverting') : t('gitView.changes.revertAll')}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -9,7 +9,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||||
|
|
||||||
interface CommitSectionProps {
|
interface CommitSectionProps {
|
||||||
selectedCount: number;
|
stagedCount: number;
|
||||||
commitMessage: string;
|
commitMessage: string;
|
||||||
onCommitMessageChange: (value: string) => void;
|
onCommitMessageChange: (value: string) => void;
|
||||||
generatedHighlights: string[];
|
generatedHighlights: string[];
|
||||||
@@ -19,12 +19,13 @@ interface CommitSectionProps {
|
|||||||
onCommit: () => void;
|
onCommit: () => void;
|
||||||
onCommitAndPush: () => void;
|
onCommitAndPush: () => void;
|
||||||
commitAction: CommitAction;
|
commitAction: CommitAction;
|
||||||
|
hasPendingIndexMutation?: boolean;
|
||||||
gitmojiEnabled: boolean;
|
gitmojiEnabled: boolean;
|
||||||
onOpenGitmojiPicker: () => void;
|
onOpenGitmojiPicker: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommitSection: React.FC<CommitSectionProps> = ({
|
export const CommitSection: React.FC<CommitSectionProps> = ({
|
||||||
selectedCount,
|
stagedCount,
|
||||||
commitMessage,
|
commitMessage,
|
||||||
onCommitMessageChange,
|
onCommitMessageChange,
|
||||||
generatedHighlights,
|
generatedHighlights,
|
||||||
@@ -34,31 +35,31 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
|||||||
onCommit,
|
onCommit,
|
||||||
onCommitAndPush,
|
onCommitAndPush,
|
||||||
commitAction,
|
commitAction,
|
||||||
|
hasPendingIndexMutation = false,
|
||||||
gitmojiEnabled,
|
gitmojiEnabled,
|
||||||
onOpenGitmojiPicker,
|
onOpenGitmojiPicker,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const hasSelectedFiles = selectedCount > 0;
|
const hasStagedFiles = stagedCount > 0;
|
||||||
const canCommit = commitMessage.trim() && hasSelectedFiles && commitAction === null;
|
const canCommit = commitMessage.trim() && hasStagedFiles && commitAction === null && !hasPendingIndexMutation;
|
||||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||||
|
|
||||||
const containerClassName = 'border-0 bg-transparent rounded-none';
|
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';
|
const contentClassName = 'flex flex-col gap-3 px-0 pt-1 pb-3';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={containerClassName}>
|
<section className={containerClassName}>
|
||||||
<div className={headerClassName}>
|
<div className={headerClassName}>
|
||||||
<h3 className="typography-ui-header font-semibold text-foreground">{t('gitView.commit.title')}</h3>
|
<h3 className="typography-ui-header font-semibold text-foreground">{t('gitView.commit.title')}</h3>
|
||||||
|
{!hasStagedFiles ? (
|
||||||
|
<span className="min-w-0 truncate typography-meta text-muted-foreground">
|
||||||
|
{t('gitView.commit.stageFilesHint')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={contentClassName}>
|
<div className={contentClassName}>
|
||||||
{!hasSelectedFiles ? (
|
|
||||||
<p className="typography-meta text-muted-foreground">
|
|
||||||
{t('gitView.commit.selectFilesHint')}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AIHighlightsBox
|
<AIHighlightsBox
|
||||||
highlights={generatedHighlights}
|
highlights={generatedHighlights}
|
||||||
onInsert={onInsertHighlights}
|
onInsert={onInsertHighlights}
|
||||||
@@ -94,7 +95,8 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
|
|||||||
disabled={
|
disabled={
|
||||||
isGeneratingMessage ||
|
isGeneratingMessage ||
|
||||||
commitAction !== null ||
|
commitAction !== null ||
|
||||||
selectedCount === 0
|
hasPendingIndexMutation ||
|
||||||
|
stagedCount === 0
|
||||||
}
|
}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('gitView.commit.generateAria')}
|
aria-label={t('gitView.commit.generateAria')}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface GitHeaderProps {
|
|||||||
isApplyingIdentity: boolean;
|
isApplyingIdentity: boolean;
|
||||||
isWorktreeMode: boolean;
|
isWorktreeMode: boolean;
|
||||||
onOpenHistory?: () => void;
|
onOpenHistory?: () => void;
|
||||||
|
onOpenStashes?: () => void;
|
||||||
actionTabItems?: SortableTabsStripItem[];
|
actionTabItems?: SortableTabsStripItem[];
|
||||||
activeActionTab?: string;
|
activeActionTab?: string;
|
||||||
onSelectActionTab?: (tabID: string) => void;
|
onSelectActionTab?: (tabID: string) => void;
|
||||||
@@ -197,6 +198,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
isApplyingIdentity,
|
isApplyingIdentity,
|
||||||
isWorktreeMode,
|
isWorktreeMode,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
|
onOpenStashes,
|
||||||
actionTabItems,
|
actionTabItems,
|
||||||
activeActionTab,
|
activeActionTab,
|
||||||
onSelectActionTab,
|
onSelectActionTab,
|
||||||
@@ -208,20 +210,38 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
|||||||
|
|
||||||
const managementButtons = (
|
const managementButtons = (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{onOpenHistory ? (
|
{onOpenHistory || onOpenStashes ? (
|
||||||
<Tooltip>
|
<DropdownMenu>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className="h-8 w-8 px-0"
|
variant="ghost"
|
||||||
onClick={onOpenHistory}
|
size="sm"
|
||||||
>
|
className="h-8 w-8 px-0"
|
||||||
<Icon name="history" className="size-4" />
|
aria-label={t('gitView.history.title')}
|
||||||
</Button>
|
>
|
||||||
</TooltipTrigger>
|
<Icon name="git-repository" className="size-4" />
|
||||||
<TooltipContent sideOffset={8}>{t('gitView.history.title')}</TooltipContent>
|
</Button>
|
||||||
</Tooltip>
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent sideOffset={8}>{t('gitView.history.title')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{onOpenHistory ? (
|
||||||
|
<DropdownMenuItem onSelect={onOpenHistory}>
|
||||||
|
<Icon name="history" className="size-4" />
|
||||||
|
{t('gitView.history.title')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{onOpenStashes ? (
|
||||||
|
<DropdownMenuItem onSelect={onOpenStashes}>
|
||||||
|
<Icon name="archive-stack" className="size-4" />
|
||||||
|
{t('gitView.stashes.title')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ interface StashesDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
directory: string | null;
|
directory: string | null;
|
||||||
hasUncommittedChanges: boolean;
|
hasUncommittedChanges: boolean;
|
||||||
|
hasStagedChanges?: boolean;
|
||||||
uncommittedFileCount: number;
|
uncommittedFileCount: number;
|
||||||
onChanged?: () => void | Promise<void>;
|
onChanged?: (change?: { affectsIndex?: boolean }) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StashOperation = 'create' | `apply:${string}` | `pop:${string}` | `drop:${string}` | null;
|
type StashOperation = 'create' | `apply:${string}` | `pop:${string}` | `drop:${string}` | null;
|
||||||
@@ -26,6 +27,7 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
directory,
|
directory,
|
||||||
hasUncommittedChanges,
|
hasUncommittedChanges,
|
||||||
|
hasStagedChanges = false,
|
||||||
uncommittedFileCount,
|
uncommittedFileCount,
|
||||||
onChanged,
|
onChanged,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -73,9 +75,9 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
|||||||
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
|
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
|
||||||
}, [query, stashes]);
|
}, [query, stashes]);
|
||||||
|
|
||||||
const refreshAfterChange = React.useCallback(async () => {
|
const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
|
||||||
await load();
|
await load();
|
||||||
await onChanged?.();
|
await onChanged?.(change);
|
||||||
}, [load, onChanged]);
|
}, [load, onChanged]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
@@ -89,7 +91,7 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
toast.info(t('gitView.stashes.toast.noChanges'));
|
toast.info(t('gitView.stashes.toast.noChanges'));
|
||||||
}
|
}
|
||||||
await refreshAfterChange();
|
await refreshAfterChange({ affectsIndex: Boolean(result.created && hasStagedChanges) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : t('gitView.stashes.toast.createFailed'));
|
toast.error(error instanceof Error ? error.message : t('gitView.stashes.toast.createFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -107,11 +109,11 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
|||||||
if (kind === 'drop') await dropGitStash(directory, { ref: stash.ref });
|
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';
|
const successKey = kind === 'apply' ? 'gitView.stashes.toast.applySuccess' : kind === 'pop' ? 'gitView.stashes.toast.popSuccess' : 'gitView.stashes.toast.dropSuccess';
|
||||||
toast.success(t(successKey));
|
toast.success(t(successKey));
|
||||||
await refreshAfterChange();
|
await refreshAfterChange({ affectsIndex: kind !== 'drop' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const failedKey = kind === 'apply' ? 'gitView.stashes.toast.applyFailed' : kind === 'pop' ? 'gitView.stashes.toast.popFailed' : 'gitView.stashes.toast.dropFailed';
|
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));
|
toast.error(error instanceof Error ? error.message : t(failedKey));
|
||||||
await refreshAfterChange();
|
await refreshAfterChange({ affectsIndex: kind !== 'drop' });
|
||||||
} finally {
|
} finally {
|
||||||
setOperation(null);
|
setOperation(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, ChangesTreeDirectoryNode>;
|
||||||
|
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<string>,
|
||||||
|
): 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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { createGitIndexMutationQueue, type GitIndexMutationDirection } from './gitIndexMutationQueue';
|
||||||
|
|
||||||
|
type Deferred<T> = {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDeferred = <T>(): Deferred<T> => {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<T>((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<void>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
export type GitIndexMutationDirection = 'stage' | 'unstage';
|
||||||
|
|
||||||
|
export type QueuedGitIndexMutation = {
|
||||||
|
directory: string;
|
||||||
|
direction: GitIndexMutationDirection;
|
||||||
|
paths: Set<string>;
|
||||||
|
rollback?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MutationSnapshot = {
|
||||||
|
directory: string;
|
||||||
|
direction: GitIndexMutationDirection;
|
||||||
|
paths: string[];
|
||||||
|
rollback?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitIndexMutationQueueOptions = {
|
||||||
|
runMutation: (mutation: MutationSnapshot) => Promise<void>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { GitHeader } from './GitHeader';
|
export { GitHeader } from './GitHeader';
|
||||||
export { GitEmptyState } from './GitEmptyState';
|
export { GitEmptyState } from './GitEmptyState';
|
||||||
export { ChangesSection } from './ChangesSection';
|
export { ChangesPanel } from './ChangesPanel';
|
||||||
|
export type { ChangesGroupConfig } from './ChangesPanel';
|
||||||
export { ChangeRow } from './ChangeRow';
|
export { ChangeRow } from './ChangeRow';
|
||||||
export { CommitSection } from './CommitSection';
|
export { CommitSection } from './CommitSection';
|
||||||
export { CommitInput } from './CommitInput';
|
export { CommitInput } from './CommitInput';
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ export interface GitRemoveRemotePayload {
|
|||||||
export interface CreateGitCommitOptions {
|
export interface CreateGitCommitOptions {
|
||||||
addAll?: boolean;
|
addAll?: boolean;
|
||||||
files?: string[];
|
files?: string[];
|
||||||
|
stageFiles?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitLogOptions {
|
export interface GitLogOptions {
|
||||||
@@ -421,7 +422,11 @@ export interface GitAPI {
|
|||||||
getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus>;
|
getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus>;
|
||||||
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
|
||||||
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
|
||||||
revertGitFile(directory: string, filePath: string): Promise<void>;
|
revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise<void>;
|
||||||
|
stageGitFile(directory: string, filePath: string): Promise<void>;
|
||||||
|
stageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
|
||||||
|
unstageGitFile(directory: string, filePath: string): Promise<void>;
|
||||||
|
unstageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
|
||||||
isLinkedWorktree(directory: string): Promise<boolean>;
|
isLinkedWorktree(directory: string): Promise<boolean>;
|
||||||
getGitBranches(directory: string): Promise<GitBranch>;
|
getGitBranches(directory: string): Promise<GitBranch>;
|
||||||
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
|
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { GitAPI, GitStatus } from "./api/types"
|
import type { GitAPI, GitStatus } from "./api/types"
|
||||||
import { getGitStatus } from "./gitApi"
|
import { getGitStatus, stageGitFile, stageGitFiles, unstageGitFile, unstageGitFiles } from "./gitApi"
|
||||||
|
|
||||||
const status: GitStatus = {
|
const status: GitStatus = {
|
||||||
current: "main",
|
current: "main",
|
||||||
@@ -48,3 +48,65 @@ describe("getGitStatus", () => {
|
|||||||
expect(received).toEqual({ directory: "/repo", options: { mode: "light" } })
|
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<GitAPI> 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<GitAPI> 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<GitAPI> 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<GitAPI> as GitAPI
|
||||||
|
|
||||||
|
await withRuntimeGit(runtimeGit, async () => {
|
||||||
|
await unstageGitFile("/repo", "a.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(received).toEqual({ directory: "/repo", path: "a.ts" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -126,10 +126,38 @@ export async function getGitFileDiff(
|
|||||||
return gitHttp.getGitFileDiff(directory, options);
|
return gitHttp.getGitFileDiff(directory, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revertGitFile(directory: string, filePath: string): Promise<void> {
|
export async function revertGitFile(
|
||||||
|
directory: string,
|
||||||
|
filePath: string,
|
||||||
|
options?: { scope?: 'all' | 'working' }
|
||||||
|
): Promise<void> {
|
||||||
const runtime = getRuntimeGit();
|
const runtime = getRuntimeGit();
|
||||||
if (runtime) return runtime.revertGitFile(directory, filePath);
|
if (runtime) return runtime.revertGitFile(directory, filePath, options);
|
||||||
return gitHttp.revertGitFile(directory, filePath);
|
return gitHttp.revertGitFile(directory, filePath, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const runtime = getRuntimeGit();
|
||||||
|
if (runtime?.unstageGitFiles) return runtime.unstageGitFiles(directory, filePaths);
|
||||||
|
return gitHttp.unstageGitFiles(directory, filePaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -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<void>): Promise<unknown> => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -200,7 +200,11 @@ export async function getGitFileDiff(directory: string, options: GetGitFileDiffO
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revertGitFile(directory: string, filePath: string): Promise<void> {
|
export async function revertGitFile(
|
||||||
|
directory: string,
|
||||||
|
filePath: string,
|
||||||
|
options?: { scope?: 'all' | 'working' }
|
||||||
|
): Promise<void> {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
throw new Error('path is required to revert git changes');
|
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), {
|
const response = await fetch(buildUrl(`${API_BASE}/revert`, directory), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path: filePath }),
|
body: JSON.stringify({ path: filePath, scope: options?.scope }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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<void> {
|
||||||
|
await stageGitFiles(directory, [filePath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await unstageGitFiles(directory, [filePath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
||||||
|
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<boolean> {
|
export async function isLinkedWorktree(directory: string): Promise<boolean> {
|
||||||
if (!directory) {
|
if (!directory) {
|
||||||
return false;
|
return false;
|
||||||
@@ -494,6 +544,7 @@ export async function createGitCommit(
|
|||||||
message,
|
message,
|
||||||
addAll: options.addAll ?? false,
|
addAll: options.addAll ?? false,
|
||||||
files: options.files,
|
files: options.files,
|
||||||
|
stageFiles: options.stageFiles,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -451,8 +451,16 @@ export const dict = {
|
|||||||
'gitView.changes.reverting': 'Reverting...',
|
'gitView.changes.reverting': 'Reverting...',
|
||||||
'gitView.changes.selectAllAria': 'Select all files',
|
'gitView.changes.selectAllAria': 'Select all files',
|
||||||
'gitView.changes.selectFileAria': 'Select File aria label',
|
'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.title': 'Changes',
|
||||||
'gitView.changes.toggleDirectorySelectionAria': 'Toggle Directory Selection aria label',
|
'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.addGitmoji': 'Add gitmoji',
|
||||||
'gitView.commit.aiHighlights.insertAria': 'Insert aria label',
|
'gitView.commit.aiHighlights.insertAria': 'Insert aria label',
|
||||||
'gitView.commit.aiHighlights.insertTooltip': 'Insert tooltip',
|
'gitView.commit.aiHighlights.insertTooltip': 'Insert tooltip',
|
||||||
@@ -467,6 +475,7 @@ export const dict = {
|
|||||||
'gitView.commit.pushAria': 'Commit and sync',
|
'gitView.commit.pushAria': 'Commit and sync',
|
||||||
'gitView.commit.pushing': 'Syncing...',
|
'gitView.commit.pushing': 'Syncing...',
|
||||||
'gitView.commit.selectFilesHint': 'Select files in Changes to enable commit.',
|
'gitView.commit.selectFilesHint': 'Select files in Changes to enable commit.',
|
||||||
|
'gitView.commit.stageFilesHint': 'Stage files to enable commit.',
|
||||||
'gitView.commit.title': 'Commit',
|
'gitView.commit.title': 'Commit',
|
||||||
'gitView.common.cancel': 'Cancel',
|
'gitView.common.cancel': 'Cancel',
|
||||||
'gitView.common.close': 'Close',
|
'gitView.common.close': 'Close',
|
||||||
@@ -775,15 +784,21 @@ export const dict = {
|
|||||||
'gitView.toast.revertedFilesSingle': 'Reverted {count} file',
|
'gitView.toast.revertedFilesSingle': 'Reverted {count} file',
|
||||||
'gitView.toast.revertedSomePlural': 'Reverted {success} files, {failed} failed',
|
'gitView.toast.revertedSomePlural': 'Reverted {success} files, {failed} failed',
|
||||||
'gitView.toast.revertedSomeSingle': 'Reverted {success} file, {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.selectFileToCommit': 'Select at least one file to commit',
|
||||||
'gitView.toast.selectFileToDescribe': 'Select at least one file to describe',
|
'gitView.toast.selectFileToDescribe': 'Select at least one file to describe',
|
||||||
'gitView.toast.stashedRestored': 'Stashed changes restored',
|
'gitView.toast.stashedRestored': 'Stashed changes restored',
|
||||||
'gitView.toast.syncActionFailed': '{action} failed',
|
'gitView.toast.syncActionFailed': '{action} failed',
|
||||||
|
'gitView.toast.unstageFileFailed': 'Failed to unstage changes',
|
||||||
'gitView.toast.upstreamSet': 'Set upstream for {branch} to {remote}',
|
'gitView.toast.upstreamSet': 'Set upstream for {branch} to {remote}',
|
||||||
'gitView.worktree.availableInWorktreeMode': 'Available only in worktree mode',
|
'gitView.worktree.availableInWorktreeMode': 'Available only in worktree mode',
|
||||||
'contextPanel.mode.chat': 'Chat',
|
'contextPanel.mode.chat': 'Chat',
|
||||||
'contextPanel.mode.files': 'Files',
|
'contextPanel.mode.files': 'Files',
|
||||||
'contextPanel.mode.diff': 'Diff',
|
'contextPanel.mode.diff': 'Diff',
|
||||||
|
'contextPanel.mode.stagedDiff': 'Staged Diff',
|
||||||
|
'contextPanel.mode.workingDiff': 'Working Diff',
|
||||||
'contextPanel.mode.plan': 'Plan',
|
'contextPanel.mode.plan': 'Plan',
|
||||||
'contextPanel.mode.context': 'Context',
|
'contextPanel.mode.context': 'Context',
|
||||||
'contextPanel.mode.preview': 'Preview',
|
'contextPanel.mode.preview': 'Preview',
|
||||||
|
|||||||
@@ -452,8 +452,16 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.changes.reverting": "Revertiendo...",
|
"gitView.changes.reverting": "Revertiendo...",
|
||||||
"gitView.changes.selectAllAria": "Seleccionar todos los archivos",
|
"gitView.changes.selectAllAria": "Seleccionar todos los archivos",
|
||||||
"gitView.changes.selectFileAria": "Seleccionar archivo",
|
"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.title": "Cambios",
|
||||||
"gitView.changes.toggleDirectorySelectionAria": "Alternar selección de directorio",
|
"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.addGitmoji": "Añadir gitmoji",
|
||||||
"gitView.commit.aiHighlights.insertAria": "Insertar",
|
"gitView.commit.aiHighlights.insertAria": "Insertar",
|
||||||
"gitView.commit.aiHighlights.insertTooltip": "Insertar",
|
"gitView.commit.aiHighlights.insertTooltip": "Insertar",
|
||||||
@@ -468,6 +476,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.commit.pushAria": "Commit and sync",
|
"gitView.commit.pushAria": "Commit and sync",
|
||||||
"gitView.commit.pushing": "Sincronizando...",
|
"gitView.commit.pushing": "Sincronizando...",
|
||||||
"gitView.commit.selectFilesHint": "Selecciona archivos en Cambios para habilitar el commit.",
|
"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.commit.title": "Commit",
|
||||||
"gitView.common.cancel": "Cancelar",
|
"gitView.common.cancel": "Cancelar",
|
||||||
"gitView.common.close": "Cerrar",
|
"gitView.common.close": "Cerrar",
|
||||||
@@ -776,15 +785,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.toast.revertedFilesSingle": "{count} archivo revertido",
|
"gitView.toast.revertedFilesSingle": "{count} archivo revertido",
|
||||||
"gitView.toast.revertedSomePlural": "{success} archivos revertidos, {failed} fallidos",
|
"gitView.toast.revertedSomePlural": "{success} archivos revertidos, {failed} fallidos",
|
||||||
"gitView.toast.revertedSomeSingle": "{success} archivo revertido, {failed} fallido",
|
"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.selectFileToCommit": "Selecciona al menos un archivo para el commit",
|
||||||
"gitView.toast.selectFileToDescribe": "Selecciona al menos un archivo para describir",
|
"gitView.toast.selectFileToDescribe": "Selecciona al menos un archivo para describir",
|
||||||
"gitView.toast.stashedRestored": "Cambios del stash restaurados",
|
"gitView.toast.stashedRestored": "Cambios del stash restaurados",
|
||||||
"gitView.toast.syncActionFailed": "{action} falló",
|
"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.toast.upstreamSet": "Upstream de {branch} configurado como {remote}",
|
||||||
"gitView.worktree.availableInWorktreeMode": "Disponible solo en modo worktree",
|
"gitView.worktree.availableInWorktreeMode": "Disponible solo en modo worktree",
|
||||||
"contextPanel.mode.chat": "Chat",
|
"contextPanel.mode.chat": "Chat",
|
||||||
"contextPanel.mode.files": "Archivos",
|
"contextPanel.mode.files": "Archivos",
|
||||||
"contextPanel.mode.diff": "Diff",
|
"contextPanel.mode.diff": "Diff",
|
||||||
|
"contextPanel.mode.stagedDiff": "Staged Diff",
|
||||||
|
"contextPanel.mode.workingDiff": "Working Diff",
|
||||||
"contextPanel.mode.plan": "Plan",
|
"contextPanel.mode.plan": "Plan",
|
||||||
"contextPanel.mode.context": "Contexto",
|
"contextPanel.mode.context": "Contexto",
|
||||||
"contextPanel.mode.preview": "Vista previa",
|
"contextPanel.mode.preview": "Vista previa",
|
||||||
|
|||||||
@@ -452,8 +452,16 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.changes.reverting': '되돌리는 중…',
|
'gitView.changes.reverting': '되돌리는 중…',
|
||||||
'gitView.changes.selectAllAria': '모든 파일 선택',
|
'gitView.changes.selectAllAria': '모든 파일 선택',
|
||||||
'gitView.changes.selectFileAria': '파일 선택',
|
'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.title': '변경사항',
|
||||||
'gitView.changes.toggleDirectorySelectionAria': '디렉터리 선택 전환',
|
'gitView.changes.toggleDirectorySelectionAria': '디렉터리 선택 전환',
|
||||||
|
'gitView.changes.unstageAllAria': '모든 변경사항 스테이징 해제',
|
||||||
|
'gitView.changes.unstageDirectoryAria': '{path}의 모든 변경사항 스테이징 해제',
|
||||||
|
'gitView.changes.unstageFileAria': '{path} 스테이징 해제',
|
||||||
'gitView.commit.addGitmoji': 'gitmoji 추가',
|
'gitView.commit.addGitmoji': 'gitmoji 추가',
|
||||||
'gitView.commit.aiHighlights.insertAria': '커밋 메시지에 삽입',
|
'gitView.commit.aiHighlights.insertAria': '커밋 메시지에 삽입',
|
||||||
'gitView.commit.aiHighlights.insertTooltip': '커밋 메시지에 삽입',
|
'gitView.commit.aiHighlights.insertTooltip': '커밋 메시지에 삽입',
|
||||||
@@ -468,6 +476,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.commit.pushAria': 'Commit and sync',
|
'gitView.commit.pushAria': 'Commit and sync',
|
||||||
'gitView.commit.pushing': 'sync 중…',
|
'gitView.commit.pushing': 'sync 중…',
|
||||||
'gitView.commit.selectFilesHint': '커밋하려면 변경 사항에서 파일을 선택하세요.',
|
'gitView.commit.selectFilesHint': '커밋하려면 변경 사항에서 파일을 선택하세요.',
|
||||||
|
'gitView.commit.stageFilesHint': '커밋하려면 파일을 스테이징하세요.',
|
||||||
'gitView.commit.title': '커밋',
|
'gitView.commit.title': '커밋',
|
||||||
'gitView.common.cancel': '취소',
|
'gitView.common.cancel': '취소',
|
||||||
'gitView.common.close': '닫기',
|
'gitView.common.close': '닫기',
|
||||||
@@ -776,15 +785,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.toast.revertedFilesSingle': '파일 {count}개 되돌림',
|
'gitView.toast.revertedFilesSingle': '파일 {count}개 되돌림',
|
||||||
'gitView.toast.revertedSomePlural': '파일 {success}개 되돌림, {failed}개 실패',
|
'gitView.toast.revertedSomePlural': '파일 {success}개 되돌림, {failed}개 실패',
|
||||||
'gitView.toast.revertedSomeSingle': '파일 {success}개 되돌림, {failed}개 실패',
|
'gitView.toast.revertedSomeSingle': '파일 {success}개 되돌림, {failed}개 실패',
|
||||||
|
'gitView.toast.stageFileFailed': '변경사항 스테이징 실패',
|
||||||
|
'gitView.toast.stageFileToCommit': '커밋하려면 파일을 하나 이상 스테이징하세요',
|
||||||
|
'gitView.toast.stageFileToDescribe': '설명하려면 파일을 하나 이상 스테이징하세요',
|
||||||
'gitView.toast.selectFileToCommit': '커밋할 파일을 하나 이상 선택하세요',
|
'gitView.toast.selectFileToCommit': '커밋할 파일을 하나 이상 선택하세요',
|
||||||
'gitView.toast.selectFileToDescribe': '설명할 파일을 하나 이상 선택하세요',
|
'gitView.toast.selectFileToDescribe': '설명할 파일을 하나 이상 선택하세요',
|
||||||
'gitView.toast.stashedRestored': 'stash한 변경 사항이 복원되었습니다',
|
'gitView.toast.stashedRestored': 'stash한 변경 사항이 복원되었습니다',
|
||||||
'gitView.toast.syncActionFailed': '{action} 실패',
|
'gitView.toast.syncActionFailed': '{action} 실패',
|
||||||
|
'gitView.toast.unstageFileFailed': '변경사항 스테이징 해제 실패',
|
||||||
'gitView.toast.upstreamSet': '{branch}의 업스트림을 {remote}(으)로 설정했습니다',
|
'gitView.toast.upstreamSet': '{branch}의 업스트림을 {remote}(으)로 설정했습니다',
|
||||||
'gitView.worktree.availableInWorktreeMode': '워크트리 모드에서만 사용할 수 있습니다',
|
'gitView.worktree.availableInWorktreeMode': '워크트리 모드에서만 사용할 수 있습니다',
|
||||||
'contextPanel.mode.chat': '채팅',
|
'contextPanel.mode.chat': '채팅',
|
||||||
'contextPanel.mode.files': '파일',
|
'contextPanel.mode.files': '파일',
|
||||||
'contextPanel.mode.diff': '변경사항',
|
'contextPanel.mode.diff': '변경사항',
|
||||||
|
'contextPanel.mode.stagedDiff': 'Staged Diff',
|
||||||
|
'contextPanel.mode.workingDiff': 'Working Diff',
|
||||||
'contextPanel.mode.plan': '계획',
|
'contextPanel.mode.plan': '계획',
|
||||||
'contextPanel.mode.context': '컨텍스트',
|
'contextPanel.mode.context': '컨텍스트',
|
||||||
'contextPanel.mode.preview': '미리보기',
|
'contextPanel.mode.preview': '미리보기',
|
||||||
|
|||||||
@@ -1073,6 +1073,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextPanel.mode.chat': 'Chat',
|
'contextPanel.mode.chat': 'Chat',
|
||||||
'contextPanel.mode.context': 'Context',
|
'contextPanel.mode.context': 'Context',
|
||||||
'contextPanel.mode.diff': 'Różnice',
|
'contextPanel.mode.diff': 'Różnice',
|
||||||
|
'contextPanel.mode.stagedDiff': 'Staged Diff',
|
||||||
|
'contextPanel.mode.workingDiff': 'Working Diff',
|
||||||
'contextPanel.mode.files': 'Pliki',
|
'contextPanel.mode.files': 'Pliki',
|
||||||
'contextPanel.mode.plan': 'Plan',
|
'contextPanel.mode.plan': 'Plan',
|
||||||
'contextPanel.mode.preview': 'Podgląd',
|
'contextPanel.mode.preview': 'Podgląd',
|
||||||
@@ -1421,8 +1423,16 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.changes.reverting': 'Cofanie...',
|
'gitView.changes.reverting': 'Cofanie...',
|
||||||
'gitView.changes.selectAllAria': 'Zaznacz wszystkie pliki',
|
'gitView.changes.selectAllAria': 'Zaznacz wszystkie pliki',
|
||||||
'gitView.changes.selectFileAria': 'Zaznacz plik',
|
'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.title': 'Zmiany',
|
||||||
'gitView.changes.toggleDirectorySelectionAria': 'Przełącz zaznaczenie katalogu',
|
'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.addGitmoji': 'Dodaj gitmoji',
|
||||||
'gitView.commit.aiHighlights.insertAria': 'Wstaw',
|
'gitView.commit.aiHighlights.insertAria': 'Wstaw',
|
||||||
'gitView.commit.aiHighlights.insertTooltip': 'Wstaw',
|
'gitView.commit.aiHighlights.insertTooltip': 'Wstaw',
|
||||||
@@ -1437,6 +1447,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.commit.pushAria': 'Wypchnij',
|
'gitView.commit.pushAria': 'Wypchnij',
|
||||||
'gitView.commit.pushing': 'Wypychanie...',
|
'gitView.commit.pushing': 'Wypychanie...',
|
||||||
'gitView.commit.selectFilesHint': 'Zaznacz pliki w sekcji Zmiany, aby włączyć commit.',
|
'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.commit.title': 'Commit',
|
||||||
'gitView.common.cancel': 'Anuluj',
|
'gitView.common.cancel': 'Anuluj',
|
||||||
'gitView.common.close': 'Zamknij',
|
'gitView.common.close': 'Zamknij',
|
||||||
@@ -1700,10 +1711,14 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.toast.revertedFilesSingle': 'Cofnięto {count} plik',
|
'gitView.toast.revertedFilesSingle': 'Cofnięto {count} plik',
|
||||||
'gitView.toast.revertedSomePlural': 'Cofnięto {success} plików, {failed} nieudanych',
|
'gitView.toast.revertedSomePlural': 'Cofnięto {success} plików, {failed} nieudanych',
|
||||||
'gitView.toast.revertedSomeSingle': 'Cofnięto {success} plik, {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.selectFileToCommit': 'Zaznacz co najmniej jeden plik do commita',
|
||||||
'gitView.toast.selectFileToDescribe': 'Zaznacz co najmniej jeden plik do opisu',
|
'gitView.toast.selectFileToDescribe': 'Zaznacz co najmniej jeden plik do opisu',
|
||||||
'gitView.toast.stashedRestored': 'Przywrócono odłożone zmiany',
|
'gitView.toast.stashedRestored': 'Przywrócono odłożone zmiany',
|
||||||
'gitView.toast.syncActionFailed': 'Operacja {action} nie powiodła się',
|
'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.toast.upstreamSet': 'Ustawiono upstream dla {branch} na {remote}',
|
||||||
'gitView.worktree.availableInWorktreeMode': 'Dostępne tylko w trybie drzewa pracy',
|
'gitView.worktree.availableInWorktreeMode': 'Dostępne tylko w trybie drzewa pracy',
|
||||||
'header.actions.backAria': 'Wstecz',
|
'header.actions.backAria': 'Wstecz',
|
||||||
|
|||||||
@@ -452,8 +452,16 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.changes.reverting": "Revertiendo...",
|
"gitView.changes.reverting": "Revertiendo...",
|
||||||
"gitView.changes.selectAllAria": "Selecionar todos os arquivos",
|
"gitView.changes.selectAllAria": "Selecionar todos os arquivos",
|
||||||
"gitView.changes.selectFileAria": "Selecionar arquivo",
|
"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.title": "Alterações",
|
||||||
"gitView.changes.toggleDirectorySelectionAria": "Alternar selección de diretório",
|
"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.addGitmoji": "Adicionar gitmoji",
|
||||||
"gitView.commit.aiHighlights.insertAria": "Insertar",
|
"gitView.commit.aiHighlights.insertAria": "Insertar",
|
||||||
"gitView.commit.aiHighlights.insertTooltip": "Insertar",
|
"gitView.commit.aiHighlights.insertTooltip": "Insertar",
|
||||||
@@ -468,6 +476,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.commit.pushAria": "Commit and sync",
|
"gitView.commit.pushAria": "Commit and sync",
|
||||||
"gitView.commit.pushing": "Sincronizando...",
|
"gitView.commit.pushing": "Sincronizando...",
|
||||||
"gitView.commit.selectFilesHint": "Selecione arquivos em Alterações para habilitar o commit.",
|
"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.commit.title": "Commit",
|
||||||
"gitView.common.cancel": "Cancelar",
|
"gitView.common.cancel": "Cancelar",
|
||||||
"gitView.common.close": "Fechar",
|
"gitView.common.close": "Fechar",
|
||||||
@@ -776,15 +785,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.toast.revertedFilesSingle": "{count} arquivo revertido",
|
"gitView.toast.revertedFilesSingle": "{count} arquivo revertido",
|
||||||
"gitView.toast.revertedSomePlural": "{success} arquivos revertidos, {failed} com falha",
|
"gitView.toast.revertedSomePlural": "{success} arquivos revertidos, {failed} com falha",
|
||||||
"gitView.toast.revertedSomeSingle": "{success} arquivo revertido, {failed} falhou",
|
"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.selectFileToCommit": "Selecione ao menos um arquivo para o commit",
|
||||||
"gitView.toast.selectFileToDescribe": "Selecione ao menos um arquivo para descrever",
|
"gitView.toast.selectFileToDescribe": "Selecione ao menos um arquivo para descrever",
|
||||||
"gitView.toast.stashedRestored": "Alterações do stash restaurados",
|
"gitView.toast.stashedRestored": "Alterações do stash restaurados",
|
||||||
"gitView.toast.syncActionFailed": "{action} falhou",
|
"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.toast.upstreamSet": "Upstream de {branch} configurado como {remote}",
|
||||||
"gitView.worktree.availableInWorktreeMode": "Disponível apenas em modo worktree",
|
"gitView.worktree.availableInWorktreeMode": "Disponível apenas em modo worktree",
|
||||||
"contextPanel.mode.chat": "Chat",
|
"contextPanel.mode.chat": "Chat",
|
||||||
"contextPanel.mode.files": "Arquivos",
|
"contextPanel.mode.files": "Arquivos",
|
||||||
"contextPanel.mode.diff": "Diff",
|
"contextPanel.mode.diff": "Diff",
|
||||||
|
"contextPanel.mode.stagedDiff": "Staged Diff",
|
||||||
|
"contextPanel.mode.workingDiff": "Working Diff",
|
||||||
"contextPanel.mode.plan": "Plano",
|
"contextPanel.mode.plan": "Plano",
|
||||||
"contextPanel.mode.context": "Contexto",
|
"contextPanel.mode.context": "Contexto",
|
||||||
"contextPanel.mode.preview": "Prévia",
|
"contextPanel.mode.preview": "Prévia",
|
||||||
|
|||||||
@@ -452,8 +452,16 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.changes.reverting": "Скасування...",
|
"gitView.changes.reverting": "Скасування...",
|
||||||
"gitView.changes.selectAllAria": "Вибрати всі файли",
|
"gitView.changes.selectAllAria": "Вибрати всі файли",
|
||||||
"gitView.changes.selectFileAria": "Вибрати файл",
|
"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.title": "Зміни",
|
||||||
"gitView.changes.toggleDirectorySelectionAria": "Перемкнути вибір каталогу",
|
"gitView.changes.toggleDirectorySelectionAria": "Перемкнути вибір каталогу",
|
||||||
|
"gitView.changes.unstageAllAria": "Прибрати всі зміни з індексу",
|
||||||
|
"gitView.changes.unstageDirectoryAria": "Прибрати з індексу всі зміни в {path}",
|
||||||
|
"gitView.changes.unstageFileAria": "Прибрати {path} з індексу",
|
||||||
"gitView.commit.addGitmoji": "Додати gitmoji",
|
"gitView.commit.addGitmoji": "Додати gitmoji",
|
||||||
"gitView.commit.aiHighlights.insertAria": "Вставити підказку",
|
"gitView.commit.aiHighlights.insertAria": "Вставити підказку",
|
||||||
"gitView.commit.aiHighlights.insertTooltip": "Вставити підказку",
|
"gitView.commit.aiHighlights.insertTooltip": "Вставити підказку",
|
||||||
@@ -468,6 +476,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.commit.pushAria": "Commit and sync",
|
"gitView.commit.pushAria": "Commit and sync",
|
||||||
"gitView.commit.pushing": "Sync...",
|
"gitView.commit.pushing": "Sync...",
|
||||||
"gitView.commit.selectFilesHint": "Виберіть файли в розділі «Зміни», щоб увімкнути коміт.",
|
"gitView.commit.selectFilesHint": "Виберіть файли в розділі «Зміни», щоб увімкнути коміт.",
|
||||||
|
"gitView.commit.stageFilesHint": "Додайте файли до індексу, щоб увімкнути коміт.",
|
||||||
"gitView.commit.title": "Коміт",
|
"gitView.commit.title": "Коміт",
|
||||||
"gitView.common.cancel": "Скасувати",
|
"gitView.common.cancel": "Скасувати",
|
||||||
"gitView.common.close": "Закрити",
|
"gitView.common.close": "Закрити",
|
||||||
@@ -776,15 +785,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.toast.revertedFilesSingle": "Скасовано зміни у файлі: {count}",
|
"gitView.toast.revertedFilesSingle": "Скасовано зміни у файлі: {count}",
|
||||||
"gitView.toast.revertedSomePlural": "Скасовано змін у файлах: {success}, не вдалося: {failed}",
|
"gitView.toast.revertedSomePlural": "Скасовано змін у файлах: {success}, не вдалося: {failed}",
|
||||||
"gitView.toast.revertedSomeSingle": "Скасовано змін у файлах: {success}, не вдалося: {failed}",
|
"gitView.toast.revertedSomeSingle": "Скасовано змін у файлах: {success}, не вдалося: {failed}",
|
||||||
|
"gitView.toast.stageFileFailed": "Не вдалося додати зміни до індексу",
|
||||||
|
"gitView.toast.stageFileToCommit": "Додайте до індексу принаймні один файл для коміту",
|
||||||
|
"gitView.toast.stageFileToDescribe": "Додайте до індексу принаймні один файл для опису",
|
||||||
"gitView.toast.selectFileToCommit": "Виберіть принаймні один файл для коміту",
|
"gitView.toast.selectFileToCommit": "Виберіть принаймні один файл для коміту",
|
||||||
"gitView.toast.selectFileToDescribe": "Виберіть хоча б один файл для опису",
|
"gitView.toast.selectFileToDescribe": "Виберіть хоча б один файл для опису",
|
||||||
"gitView.toast.stashedRestored": "Зміни зі stash відновлено",
|
"gitView.toast.stashedRestored": "Зміни зі stash відновлено",
|
||||||
"gitView.toast.syncActionFailed": "{action} не вдалося",
|
"gitView.toast.syncActionFailed": "{action} не вдалося",
|
||||||
|
"gitView.toast.unstageFileFailed": "Не вдалося прибрати зміни з індексу",
|
||||||
"gitView.toast.upstreamSet": "Upstream для {branch} встановлено на {remote}",
|
"gitView.toast.upstreamSet": "Upstream для {branch} встановлено на {remote}",
|
||||||
"gitView.worktree.availableInWorktreeMode": "Доступно лише в режимі worktree",
|
"gitView.worktree.availableInWorktreeMode": "Доступно лише в режимі worktree",
|
||||||
"contextPanel.mode.chat": "Чат",
|
"contextPanel.mode.chat": "Чат",
|
||||||
"contextPanel.mode.files": "Файли",
|
"contextPanel.mode.files": "Файли",
|
||||||
"contextPanel.mode.diff": "Diff",
|
"contextPanel.mode.diff": "Diff",
|
||||||
|
"contextPanel.mode.stagedDiff": "Staged Diff",
|
||||||
|
"contextPanel.mode.workingDiff": "Working Diff",
|
||||||
"contextPanel.mode.plan": "План",
|
"contextPanel.mode.plan": "План",
|
||||||
"contextPanel.mode.context": "Контекст",
|
"contextPanel.mode.context": "Контекст",
|
||||||
"contextPanel.mode.preview": "Перегляд",
|
"contextPanel.mode.preview": "Перегляд",
|
||||||
|
|||||||
@@ -452,8 +452,16 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.changes.reverting': '正在还原...',
|
'gitView.changes.reverting': '正在还原...',
|
||||||
'gitView.changes.selectAllAria': '全选文件',
|
'gitView.changes.selectAllAria': '全选文件',
|
||||||
'gitView.changes.selectFileAria': '选择 {path}',
|
'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.title': '更改',
|
||||||
'gitView.changes.toggleDirectorySelectionAria': '切换目录 {path} 的选择',
|
'gitView.changes.toggleDirectorySelectionAria': '切换目录 {path} 的选择',
|
||||||
|
'gitView.changes.unstageAllAria': '取消暂存所有更改',
|
||||||
|
'gitView.changes.unstageDirectoryAria': '取消暂存 {path} 中的所有更改',
|
||||||
|
'gitView.changes.unstageFileAria': '取消暂存 {path}',
|
||||||
'gitView.commit.addGitmoji': '添加 gitmoji',
|
'gitView.commit.addGitmoji': '添加 gitmoji',
|
||||||
'gitView.commit.aiHighlights.insertAria': '将高亮插入提交信息',
|
'gitView.commit.aiHighlights.insertAria': '将高亮插入提交信息',
|
||||||
'gitView.commit.aiHighlights.insertTooltip': '将高亮追加到提交信息',
|
'gitView.commit.aiHighlights.insertTooltip': '将高亮追加到提交信息',
|
||||||
@@ -468,6 +476,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.commit.pushAria': '提交并同步',
|
'gitView.commit.pushAria': '提交并同步',
|
||||||
'gitView.commit.pushing': '同步中...',
|
'gitView.commit.pushing': '同步中...',
|
||||||
'gitView.commit.selectFilesHint': '在“更改”中选择文件以启用提交。',
|
'gitView.commit.selectFilesHint': '在“更改”中选择文件以启用提交。',
|
||||||
|
'gitView.commit.stageFilesHint': '暂存文件以启用提交。',
|
||||||
'gitView.commit.title': '提交',
|
'gitView.commit.title': '提交',
|
||||||
'gitView.common.cancel': '取消',
|
'gitView.common.cancel': '取消',
|
||||||
'gitView.common.close': '关闭',
|
'gitView.common.close': '关闭',
|
||||||
@@ -776,15 +785,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.toast.revertedFilesSingle': '已回退 {count} 个文件',
|
'gitView.toast.revertedFilesSingle': '已回退 {count} 个文件',
|
||||||
'gitView.toast.revertedSomePlural': '已回退 {success} 个文件,{failed} 个失败',
|
'gitView.toast.revertedSomePlural': '已回退 {success} 个文件,{failed} 个失败',
|
||||||
'gitView.toast.revertedSomeSingle': '已回退 {success} 个文件,{failed} 个失败',
|
'gitView.toast.revertedSomeSingle': '已回退 {success} 个文件,{failed} 个失败',
|
||||||
|
'gitView.toast.stageFileFailed': '暂存更改失败',
|
||||||
|
'gitView.toast.stageFileToCommit': '请至少暂存一个文件再提交',
|
||||||
|
'gitView.toast.stageFileToDescribe': '请至少暂存一个文件再生成描述',
|
||||||
'gitView.toast.selectFileToCommit': '请至少选择一个文件再提交',
|
'gitView.toast.selectFileToCommit': '请至少选择一个文件再提交',
|
||||||
'gitView.toast.selectFileToDescribe': '请至少选择一个文件再生成描述',
|
'gitView.toast.selectFileToDescribe': '请至少选择一个文件再生成描述',
|
||||||
'gitView.toast.stashedRestored': '已恢复储藏的更改',
|
'gitView.toast.stashedRestored': '已恢复储藏的更改',
|
||||||
'gitView.toast.syncActionFailed': '{action} 失败',
|
'gitView.toast.syncActionFailed': '{action} 失败',
|
||||||
|
'gitView.toast.unstageFileFailed': '取消暂存更改失败',
|
||||||
'gitView.toast.upstreamSet': '已将 {branch} 的上游设置为 {remote}',
|
'gitView.toast.upstreamSet': '已将 {branch} 的上游设置为 {remote}',
|
||||||
'gitView.worktree.availableInWorktreeMode': '仅在工作树模式下可用',
|
'gitView.worktree.availableInWorktreeMode': '仅在工作树模式下可用',
|
||||||
'contextPanel.mode.chat': '聊天',
|
'contextPanel.mode.chat': '聊天',
|
||||||
'contextPanel.mode.files': '文件',
|
'contextPanel.mode.files': '文件',
|
||||||
'contextPanel.mode.diff': '差异',
|
'contextPanel.mode.diff': '差异',
|
||||||
|
'contextPanel.mode.stagedDiff': 'Staged Diff',
|
||||||
|
'contextPanel.mode.workingDiff': 'Working Diff',
|
||||||
'contextPanel.mode.plan': '计划',
|
'contextPanel.mode.plan': '计划',
|
||||||
'contextPanel.mode.context': '上下文',
|
'contextPanel.mode.context': '上下文',
|
||||||
'contextPanel.mode.preview': '预览',
|
'contextPanel.mode.preview': '预览',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Deferred<T> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type GitAPI = Parameters<ReturnType<typeof useGitStore.getState>['fetchStatus']>[1];
|
type GitAPI = Parameters<ReturnType<typeof useGitStore.getState>['fetchStatus']>[1];
|
||||||
|
type DirectoryGitState = NonNullable<ReturnType<ReturnType<typeof useGitStore.getState>['getDirectoryState']>>;
|
||||||
|
|
||||||
const createDeferred = <T>(): Deferred<T> => {
|
const createDeferred = <T>(): Deferred<T> => {
|
||||||
let resolve!: (value: T) => void;
|
let resolve!: (value: T) => void;
|
||||||
@@ -20,16 +21,44 @@ const createDeferred = <T>(): Deferred<T> => {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
};
|
};
|
||||||
|
|
||||||
const createStatus = (diffStats?: GitStatus['diffStats']): GitStatus => ({
|
const createStatus = (diffStats?: GitStatus['diffStats'], files: GitStatus['files'] = []): GitStatus => ({
|
||||||
current: 'main',
|
current: 'main',
|
||||||
tracking: null,
|
tracking: null,
|
||||||
ahead: 0,
|
ahead: 0,
|
||||||
behind: 0,
|
behind: 0,
|
||||||
files: [],
|
files,
|
||||||
isClean: true,
|
isClean: files.length === 0,
|
||||||
diffStats,
|
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 => ({
|
const createGitApi = (getGitStatus: GitAPI['getGitStatus']): GitAPI => ({
|
||||||
checkIsGitRepository: async () => true,
|
checkIsGitRepository: async () => true,
|
||||||
getGitStatus,
|
getGitStatus,
|
||||||
@@ -91,4 +120,134 @@ describe('useGitStore', () => {
|
|||||||
const [fullResult, lightResult] = await Promise.all([fullPromise, lightPromise]);
|
const [fullResult, lightResult] = await Promise.all([fullPromise, lightPromise]);
|
||||||
expect(lightResult).toBe(fullResult);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface DirectoryGitState {
|
|||||||
log: GitLogResponse | null;
|
log: GitLogResponse | null;
|
||||||
identity: GitIdentitySummary | null;
|
identity: GitIdentitySummary | null;
|
||||||
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>;
|
diffCache: Map<string, { original: string; modified: string; fetchedAt: number; isBinary?: boolean }>;
|
||||||
|
indexRevision: number;
|
||||||
lastRepoCheckAt: number;
|
lastRepoCheckAt: number;
|
||||||
lastStatusFetch: number;
|
lastStatusFetch: number;
|
||||||
lastStatusChange: number;
|
lastStatusChange: number;
|
||||||
@@ -61,6 +62,9 @@ interface GitStore {
|
|||||||
|
|
||||||
ensureStatus: (directory: string, git: GitAPI) => Promise<void>;
|
ensureStatus: (directory: string, git: GitAPI) => Promise<void>;
|
||||||
ensureAll: (directory: string, git: GitAPI) => Promise<void>;
|
ensureAll: (directory: string, git: GitAPI) => Promise<void>;
|
||||||
|
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;
|
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;
|
setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }) => void;
|
||||||
@@ -122,6 +126,7 @@ const createEmptyDirectoryState = (): DirectoryGitState => ({
|
|||||||
log: null,
|
log: null,
|
||||||
identity: null,
|
identity: null,
|
||||||
diffCache: new Map(),
|
diffCache: new Map(),
|
||||||
|
indexRevision: 0,
|
||||||
lastRepoCheckAt: 0,
|
lastRepoCheckAt: 0,
|
||||||
lastStatusFetch: 0,
|
lastStatusFetch: 0,
|
||||||
lastStatusChange: 0,
|
lastStatusChange: 0,
|
||||||
@@ -278,6 +283,72 @@ const getChangedFilePaths = (oldStatus: GitStatus | null, newStatus: GitStatus |
|
|||||||
return changed;
|
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<string>([...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<GitStore>()(
|
export const useGitStore = create<GitStore>()(
|
||||||
devtools(
|
devtools(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -369,6 +440,7 @@ export const useGitStore = create<GitStore>()(
|
|||||||
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
const currentDirState = newDirectories.get(directory) ?? createEmptyDirectoryState();
|
||||||
|
|
||||||
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
|
const changedPaths = getChangedFilePaths(currentDirState.status, newStatus);
|
||||||
|
const indexStatusChanged = hasIndexStatusChanged(currentDirState.status, newStatus);
|
||||||
|
|
||||||
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
|
const oldPaths = new Set((currentDirState.status?.files ?? []).map((f) => f.path));
|
||||||
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
|
const newPaths = new Set((newStatus.files ?? []).map((f) => f.path));
|
||||||
@@ -402,6 +474,7 @@ export const useGitStore = create<GitStore>()(
|
|||||||
isGitRepo: true,
|
isGitRepo: true,
|
||||||
status: mergedStatus,
|
status: mergedStatus,
|
||||||
diffCache: nextDiffCache,
|
diffCache: nextDiffCache,
|
||||||
|
indexRevision: indexStatusChanged ? currentDirState.indexRevision + 1 : currentDirState.indexRevision,
|
||||||
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
|
lastRepoCheckAt: shouldProbeRepository ? now : currentDirState.lastRepoCheckAt,
|
||||||
lastStatusFetch: Date.now(),
|
lastStatusFetch: Date.now(),
|
||||||
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
|
lastStatusChange: hasFileContentChange ? Date.now() : currentDirState.lastStatusChange,
|
||||||
@@ -445,6 +518,95 @@ export const useGitStore = create<GitStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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) => {
|
fetchBranches: async (directory, git) => {
|
||||||
{
|
{
|
||||||
const newDirectories = new Map(get().directories);
|
const newDirectories = new Map(get().directories);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type ContextPanelTab = {
|
|||||||
dedupeKey: string;
|
dedupeKey: string;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
stagedDiff: boolean;
|
||||||
touchedAt: number;
|
touchedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ type ContextPanelTabDescriptor = {
|
|||||||
dedupeKey?: string | null;
|
dedupeKey?: string | null;
|
||||||
label?: string | null;
|
label?: string | null;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
stagedDiff?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ContextPanelDirectoryState = {
|
type ContextPanelDirectoryState = {
|
||||||
@@ -204,6 +206,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
|
|||||||
dedupeKey,
|
dedupeKey,
|
||||||
label: normalizeContextTabLabel(descriptor.label),
|
label: normalizeContextTabLabel(descriptor.label),
|
||||||
readOnly: descriptor.readOnly === true,
|
readOnly: descriptor.readOnly === true,
|
||||||
|
stagedDiff: descriptor.stagedDiff === true,
|
||||||
touchedAt: Date.now(),
|
touchedAt: Date.now(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -243,6 +246,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
|||||||
dedupeKey?: unknown;
|
dedupeKey?: unknown;
|
||||||
label?: unknown;
|
label?: unknown;
|
||||||
readOnly?: unknown;
|
readOnly?: unknown;
|
||||||
|
stagedDiff?: unknown;
|
||||||
touchedAt?: unknown;
|
touchedAt?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,6 +273,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
|
|||||||
dedupeKey,
|
dedupeKey,
|
||||||
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
|
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
|
||||||
readOnly: candidate.readOnly === true,
|
readOnly: candidate.readOnly === true,
|
||||||
|
stagedDiff: candidate.stagedDiff === true,
|
||||||
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
||||||
? candidate.touchedAt
|
? candidate.touchedAt
|
||||||
: Date.now(),
|
: Date.now(),
|
||||||
@@ -327,6 +332,7 @@ const upsertContextPanelTab = (
|
|||||||
targetPath: nextTab.targetPath,
|
targetPath: nextTab.targetPath,
|
||||||
dedupeKey: nextTab.dedupeKey,
|
dedupeKey: nextTab.dedupeKey,
|
||||||
label: nextTab.label,
|
label: nextTab.label,
|
||||||
|
stagedDiff: nextTab.stagedDiff,
|
||||||
touchedAt: Date.now(),
|
touchedAt: Date.now(),
|
||||||
}
|
}
|
||||||
: tab));
|
: tab));
|
||||||
@@ -504,6 +510,7 @@ interface UIStore {
|
|||||||
mainTabGuard: MainTabGuard | null;
|
mainTabGuard: MainTabGuard | null;
|
||||||
sidebarOpenBeforeFullscreenTab: boolean | null;
|
sidebarOpenBeforeFullscreenTab: boolean | null;
|
||||||
pendingDiffFile: string | null;
|
pendingDiffFile: string | null;
|
||||||
|
pendingDiffStaged: boolean;
|
||||||
pendingFileNavigation: PendingFileNavigation | null;
|
pendingFileNavigation: PendingFileNavigation | null;
|
||||||
pendingFileFocusPath: string | null;
|
pendingFileFocusPath: string | null;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
@@ -611,7 +618,7 @@ interface UIStore {
|
|||||||
setRightSidebarWidth: (width: number) => void;
|
setRightSidebarWidth: (width: number) => void;
|
||||||
setRightSidebarTab: (tab: RightSidebarTab) => void;
|
setRightSidebarTab: (tab: RightSidebarTab) => void;
|
||||||
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => 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;
|
openContextFile: (directory: string, filePath: string) => void;
|
||||||
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
|
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
|
||||||
openContextOverview: (directory: string) => void;
|
openContextOverview: (directory: string) => void;
|
||||||
@@ -635,10 +642,10 @@ interface UIStore {
|
|||||||
setSessionDropdownOpen: (open: boolean) => void;
|
setSessionDropdownOpen: (open: boolean) => void;
|
||||||
setActiveMainTab: (tab: MainTab) => void;
|
setActiveMainTab: (tab: MainTab) => void;
|
||||||
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
setMainTabGuard: (guard: MainTabGuard | null) => void;
|
||||||
setPendingDiffFile: (filePath: string | null) => void;
|
setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
|
||||||
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
|
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
|
||||||
setPendingFileFocusPath: (path: string | null) => void;
|
setPendingFileFocusPath: (path: string | null) => void;
|
||||||
navigateToDiff: (filePath: string) => void;
|
navigateToDiff: (filePath: string, staged?: boolean) => void;
|
||||||
consumePendingDiffFile: () => string | null;
|
consumePendingDiffFile: () => string | null;
|
||||||
setIsMobile: (isMobile: boolean) => void;
|
setIsMobile: (isMobile: boolean) => void;
|
||||||
toggleCommandPalette: () => void;
|
toggleCommandPalette: () => void;
|
||||||
@@ -772,6 +779,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
mainTabGuard: null,
|
mainTabGuard: null,
|
||||||
sidebarOpenBeforeFullscreenTab: null,
|
sidebarOpenBeforeFullscreenTab: null,
|
||||||
pendingDiffFile: null,
|
pendingDiffFile: null,
|
||||||
|
pendingDiffStaged: false,
|
||||||
pendingFileNavigation: null,
|
pendingFileNavigation: null,
|
||||||
pendingFileFocusPath: null,
|
pendingFileFocusPath: null,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
@@ -975,15 +983,19 @@ export const useUIStore = create<UIStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
openContextDiff: (directory, filePath) => {
|
openContextDiff: (directory, filePath, staged = false) => {
|
||||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||||
const normalizedFilePath = (filePath || '').trim();
|
const normalizedFilePath = (filePath || '').trim();
|
||||||
if (!normalizedDirectory || !normalizedFilePath) {
|
if (!normalizedDirectory || !normalizedFilePath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
get().openContextPanelTab(normalizedDirectory, { mode: 'diff', targetPath: normalizedFilePath });
|
get().openContextPanelTab(normalizedDirectory, {
|
||||||
get().setPendingDiffFile(normalizedFilePath);
|
mode: 'diff',
|
||||||
|
targetPath: normalizedFilePath,
|
||||||
|
dedupeKey: staged ? 'staged' : null,
|
||||||
|
stagedDiff: staged,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
openContextFile: (directory, filePath) => {
|
openContextFile: (directory, filePath) => {
|
||||||
@@ -1334,8 +1346,8 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ activeMainTab: tab });
|
set({ activeMainTab: tab });
|
||||||
},
|
},
|
||||||
|
|
||||||
setPendingDiffFile: (filePath) => {
|
setPendingDiffFile: (filePath, staged = false) => {
|
||||||
set({ pendingDiffFile: filePath });
|
set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
|
||||||
},
|
},
|
||||||
|
|
||||||
setPendingFileNavigation: (navigation) => {
|
setPendingFileNavigation: (navigation) => {
|
||||||
@@ -1346,18 +1358,18 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ pendingFileFocusPath: path });
|
set({ pendingFileFocusPath: path });
|
||||||
},
|
},
|
||||||
|
|
||||||
navigateToDiff: (filePath) => {
|
navigateToDiff: (filePath, staged = false) => {
|
||||||
const guard = get().mainTabGuard;
|
const guard = get().mainTabGuard;
|
||||||
if (guard && !guard('diff')) {
|
if (guard && !guard('diff')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
set({ pendingDiffFile: filePath, activeMainTab: 'diff' });
|
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, activeMainTab: 'diff' });
|
||||||
},
|
},
|
||||||
|
|
||||||
consumePendingDiffFile: () => {
|
consumePendingDiffFile: () => {
|
||||||
const { pendingDiffFile } = get();
|
const { pendingDiffFile } = get();
|
||||||
if (pendingDiffFile) {
|
if (pendingDiffFile) {
|
||||||
set({ pendingDiffFile: null });
|
set({ pendingDiffFile: null, pendingDiffStaged: false });
|
||||||
}
|
}
|
||||||
return pendingDiffFile;
|
return pendingDiffFile;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -217,25 +217,48 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'api:git/revert': {
|
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) {
|
if (!directory || !filePath) {
|
||||||
return { id, type, success: false, error: 'Directory and path are required' };
|
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 } };
|
return { id, type, success: true, data: { success: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'api:git/commit': {
|
case 'api:git/commit': {
|
||||||
const { directory, message, addAll, files } = (payload || {}) as {
|
const { directory, message, addAll, files, stageFiles } = (payload || {}) as {
|
||||||
directory?: string;
|
directory?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
addAll?: boolean;
|
addAll?: boolean;
|
||||||
files?: string[];
|
files?: string[];
|
||||||
|
stageFiles?: string[];
|
||||||
};
|
};
|
||||||
if (!directory || !message) {
|
if (!directory || !message) {
|
||||||
return { id, type, success: false, error: 'Directory and message are required' };
|
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 };
|
return { id, type, success: true, data: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,10 @@ function mapStatus(status: Status): string {
|
|||||||
return statusMap[status] || ' ';
|
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
|
* Get git status for a directory
|
||||||
*/
|
*/
|
||||||
@@ -417,7 +421,7 @@ export async function getGitStatus(directory: string, options?: GitStatusOptions
|
|||||||
|
|
||||||
// Process index changes (staged)
|
// Process index changes (staged)
|
||||||
for (const change of state.indexChanges) {
|
for (const change of state.indexChanges) {
|
||||||
const relativePath = vscode.workspace.asRelativePath(change.uri, false);
|
const relativePath = getRepositoryRelativePath(repo, change.uri);
|
||||||
files.push({
|
files.push({
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
index: mapStatus(change.status),
|
index: mapStatus(change.status),
|
||||||
@@ -427,7 +431,7 @@ export async function getGitStatus(directory: string, options?: GitStatusOptions
|
|||||||
|
|
||||||
// Process working tree changes (unstaged)
|
// Process working tree changes (unstaged)
|
||||||
for (const change of state.workingTreeChanges) {
|
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);
|
const existing = files.find(f => f.path === relativePath);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.working_dir = mapStatus(change.status);
|
existing.working_dir = mapStatus(change.status);
|
||||||
@@ -2085,10 +2089,15 @@ export async function getGitFileDiff(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the current file content
|
let modified: string;
|
||||||
const fileUri = vscode.Uri.file(path.join(directory, filePath));
|
if (staged) {
|
||||||
const modifiedBytes = await vscode.workspace.fs.readFile(fileUri);
|
const stagedResult = await execGit(['show', `:${filePath}`], directory);
|
||||||
const modified = Buffer.from(modifiedBytes).toString('utf8');
|
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 };
|
return { original, modified, path: filePath };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2103,20 +2112,101 @@ export async function getGitFileDiff(
|
|||||||
/**
|
/**
|
||||||
* Revert a file to its last committed state
|
* Revert a file to its last committed state
|
||||||
*/
|
*/
|
||||||
export async function revertGitFile(directory: string, filePath: string): Promise<void> {
|
export async function revertGitFile(
|
||||||
const repo = await getRepository(directory);
|
directory: string,
|
||||||
|
filePath: string,
|
||||||
|
options: { scope?: 'all' | 'working' } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
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 (repo) {
|
if (scope === 'all') {
|
||||||
try {
|
const unstage = await execGit(['restore', '--staged', '--', filePath], directory);
|
||||||
await repo.revert([filePath]);
|
if (unstage.exitCode !== 0) {
|
||||||
return;
|
await execGit(['reset', 'HEAD', '--', filePath], directory);
|
||||||
} catch (error) {
|
|
||||||
console.error('[GitService] Failed to revert via API:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to raw git
|
const restore = await execGit(['restore', '--', filePath], directory);
|
||||||
await execGit(['checkout', '--', 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<void> {
|
||||||
|
await stageGitFiles(directory, [filePath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await unstageGitFiles(directory, [filePath]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
|
||||||
|
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 ==============
|
// ============== Commit Operations ==============
|
||||||
@@ -2138,8 +2228,49 @@ export interface GitCommitResult {
|
|||||||
export async function createGitCommit(
|
export async function createGitCommit(
|
||||||
directory: string,
|
directory: string,
|
||||||
message: string,
|
message: string,
|
||||||
options?: { addAll?: boolean; files?: string[] }
|
options?: { addAll?: boolean; files?: string[]; stageFiles?: string[] }
|
||||||
): Promise<GitCommitResult> {
|
): Promise<GitCommitResult> {
|
||||||
|
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);
|
const repo = await getRepository(directory);
|
||||||
|
|
||||||
if (repo) {
|
if (repo) {
|
||||||
@@ -2147,7 +2278,10 @@ export async function createGitCommit(
|
|||||||
if (options?.addAll) {
|
if (options?.addAll) {
|
||||||
await repo.add(['.']);
|
await repo.add(['.']);
|
||||||
} else if (options?.files?.length) {
|
} 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);
|
await repo.commit(message);
|
||||||
@@ -2168,7 +2302,10 @@ export async function createGitCommit(
|
|||||||
if (options?.addAll) {
|
if (options?.addAll) {
|
||||||
await execGit(['add', '-A'], directory);
|
await execGit(['add', '-A'], directory);
|
||||||
} else if (options?.files?.length) {
|
} 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);
|
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 }> {
|
export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
|
||||||
const ref = options.ref || 'stash@{0}';
|
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);
|
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');
|
if (result.exitCode !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to apply stash');
|
||||||
return { success: true, ref };
|
return { success: true, ref };
|
||||||
|
|||||||
@@ -63,8 +63,24 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
revertGitFile: async (directory: string, filePath: string): Promise<void> => {
|
revertGitFile: async (directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise<void> => {
|
||||||
await sendBridgeMessage('api:git/revert', { directory, path: filePath });
|
await sendBridgeMessage('api:git/revert', { directory, path: filePath, scope: options?.scope });
|
||||||
|
},
|
||||||
|
|
||||||
|
stageGitFile: async (directory: string, filePath: string): Promise<void> => {
|
||||||
|
await sendBridgeMessage('api:git/stage', { directory, path: filePath });
|
||||||
|
},
|
||||||
|
|
||||||
|
stageGitFiles: async (directory: string, filePaths: string[]): Promise<void> => {
|
||||||
|
await sendBridgeMessage('api:git/stage', { directory, paths: filePaths });
|
||||||
|
},
|
||||||
|
|
||||||
|
unstageGitFile: async (directory: string, filePath: string): Promise<void> => {
|
||||||
|
await sendBridgeMessage('api:git/unstage', { directory, path: filePath });
|
||||||
|
},
|
||||||
|
|
||||||
|
unstageGitFiles: async (directory: string, filePaths: string[]): Promise<void> => {
|
||||||
|
await sendBridgeMessage('api:git/unstage', { directory, paths: filePaths });
|
||||||
},
|
},
|
||||||
|
|
||||||
isLinkedWorktree: async (directory: string): Promise<boolean> => {
|
isLinkedWorktree: async (directory: string): Promise<boolean> => {
|
||||||
@@ -182,6 +198,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
|||||||
message,
|
message,
|
||||||
addAll: options?.addAll,
|
addAll: options?.addAll,
|
||||||
files: options?.files,
|
files: options?.files,
|
||||||
|
stageFiles: options?.stageFiles,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- `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).
|
- `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.
|
- `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
|
### Branch Operations
|
||||||
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
|
- `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).
|
- `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
|
||||||
|
|
||||||
### Commit and Remote Operations
|
### 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.
|
- `pull(directory, options)`: Pull changes from remote.
|
||||||
- `push(directory, options)`: Push changes to remote (auto-sets upstream if needed).
|
- `push(directory, options)`: Push changes to remote (auto-sets upstream if needed).
|
||||||
- `fetch(directory, options)`: Fetch changes from remote.
|
- `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.
|
- `mergeInProgress`: Object with `{ head, message }` if merge in progress.
|
||||||
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase 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
|
### Worktree Create/Remove Response
|
||||||
- `head`: HEAD commit SHA.
|
- `head`: HEAD commit SHA.
|
||||||
- `name`: Worktree name.
|
- `name`: Worktree name.
|
||||||
|
|||||||
@@ -291,12 +291,12 @@ export function registerGitRoutes(app) {
|
|||||||
return res.status(400).json({ error: 'directory parameter is required' });
|
return res.status(400).json({ error: 'directory parameter is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path } = req.body || {};
|
const { path, scope } = req.body || {};
|
||||||
if (!path || typeof path !== 'string') {
|
if (!path || typeof path !== 'string') {
|
||||||
return res.status(400).json({ error: 'path parameter is required' });
|
return res.status(400).json({ error: 'path parameter is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
await revertFile(directory, path);
|
await revertFile(directory, path, { scope });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to revert git file:', 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) => {
|
app.post('/api/git/pull', async (req, res) => {
|
||||||
const { pull } = await getGitLibraries();
|
const { pull } = await getGitLibraries();
|
||||||
try {
|
try {
|
||||||
@@ -581,7 +625,7 @@ export function registerGitRoutes(app) {
|
|||||||
return res.status(400).json({ error: 'directory parameter is required' });
|
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) {
|
if (!message) {
|
||||||
return res.status(400).json({ error: 'message is required' });
|
return res.status(400).json({ error: 'message is required' });
|
||||||
}
|
}
|
||||||
@@ -589,6 +633,7 @@ export function registerGitRoutes(app) {
|
|||||||
const result = await commit(directory, message, {
|
const result = await commit(directory, message, {
|
||||||
addAll,
|
addAll,
|
||||||
files,
|
files,
|
||||||
|
stageFiles,
|
||||||
});
|
});
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ const execFileAsync = promisify(execFile);
|
|||||||
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
||||||
let resolvedGitBinary = null;
|
let resolvedGitBinary = null;
|
||||||
const worktreeBootstrapState = new Map();
|
const worktreeBootstrapState = new Map();
|
||||||
|
const gitIndexMutationQueues = new Map();
|
||||||
|
|
||||||
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
|
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
|
||||||
const WORKTREE_BOOTSTRAP_READY = 'ready';
|
const WORKTREE_BOOTSTRAP_READY = 'ready';
|
||||||
@@ -307,6 +308,60 @@ const normalizeDirectoryPath = (value) => {
|
|||||||
return trimmed;
|
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 toGitPath = (value) => value.replace(/\\/g, '/');
|
||||||
|
|
||||||
const isInsideOrSameDirectory = (root, target) => {
|
const isInsideOrSameDirectory = (root, target) => {
|
||||||
@@ -1811,14 +1866,30 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
|||||||
|
|
||||||
let modified = '';
|
let modified = '';
|
||||||
try {
|
try {
|
||||||
const stat = await fsp.stat(absolutePath);
|
if (staged) {
|
||||||
if (stat.isFile()) {
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
// For images, read as binary and convert to data URL
|
const { stdout } = await execFileAsync(getGitBinary(), ['show', `:${repoPath}`], {
|
||||||
const buffer = await fsp.readFile(absolutePath);
|
cwd: repoRoot,
|
||||||
modified = `data:${mimeType};base64,${buffer.toString('base64')}`;
|
encoding: 'buffer',
|
||||||
|
windowsHide: true,
|
||||||
|
maxBuffer: 50 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (stdout && stdout.length > 0) {
|
||||||
|
modified = `data:${mimeType};base64,${stdout.toString('base64')}`;
|
||||||
|
}
|
||||||
} else {
|
} 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) {
|
} catch (error) {
|
||||||
@@ -1838,52 +1909,57 @@ export async function getFileDiff(directory, { path: filePath, staged = false }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revertFile(directory, filePath) {
|
export async function revertFile(directory, filePath, options = {}) {
|
||||||
const directoryPath = normalizeDirectoryPath(directory);
|
return withGitIndexMutationQueue(directory, async () => {
|
||||||
const directoryGit = await createGit(directoryPath);
|
const scope = options?.scope === 'working' ? 'working' : 'all';
|
||||||
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
|
const directoryPath = normalizeDirectoryPath(directory);
|
||||||
const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
const directoryGit = await createGit(directoryPath);
|
||||||
const git = await createGit(repoRoot);
|
const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit);
|
||||||
|
const { absolutePath, repoPath } = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||||
|
const git = await createGit(repoRoot);
|
||||||
|
|
||||||
const isTracked = await git
|
const isTracked = await git
|
||||||
.raw(['ls-files', '--error-unmatch', '--', repoPath])
|
.raw(['ls-files', '--error-unmatch', '--', repoPath])
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
|
|
||||||
if (!isTracked) {
|
if (!isTracked) {
|
||||||
try {
|
|
||||||
await git.raw(['clean', '-f', '-d', '--', repoPath]);
|
|
||||||
return;
|
|
||||||
} catch (cleanError) {
|
|
||||||
try {
|
try {
|
||||||
await fsp.rm(absolutePath, { recursive: true, force: true });
|
await git.raw(['clean', '-f', '-d', '--', repoPath]);
|
||||||
return;
|
return;
|
||||||
} catch (fsError) {
|
} catch (cleanError) {
|
||||||
if (fsError && typeof fsError === 'object' && fsError.code === 'ENOENT') {
|
try {
|
||||||
|
await fsp.rm(absolutePath, { recursive: true, force: true });
|
||||||
return;
|
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 {
|
if (scope === 'all') {
|
||||||
await git.raw(['restore', '--staged', '--', repoPath]);
|
try {
|
||||||
} catch (error) {
|
await git.raw(['restore', '--staged', '--', repoPath]);
|
||||||
await git.raw(['reset', 'HEAD', '--', repoPath]).catch(() => {});
|
} 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;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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 = []) {
|
export async function collectDiffs(directory, files = []) {
|
||||||
@@ -1981,7 +2057,12 @@ export async function stashPush(directory, options = {}) {
|
|||||||
export async function stashApply(directory, options = {}) {
|
export async function stashApply(directory, options = {}) {
|
||||||
const { git } = await createRepositoryGitContext(directory);
|
const { git } = await createRepositoryGitContext(directory);
|
||||||
const ref = typeof options.ref === 'string' && options.ref.trim() ? options.ref.trim() : 'stash@{0}';
|
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 };
|
return { success: true, ref };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2172,77 +2253,197 @@ export async function fetch(directory, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commit(directory, message, options = {}) {
|
export async function stageFile(directory, filePath) {
|
||||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
await stageFiles(directory, [filePath]);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
export async function stageFiles(directory, paths) {
|
||||||
const requestedFiles = Array.isArray(options.files)
|
if (!directory) {
|
||||||
? options.files
|
throw new Error('directory and path are required for stageFile');
|
||||||
.map((value) => String(value || '').trim())
|
}
|
||||||
.filter(Boolean)
|
|
||||||
: [];
|
|
||||||
let filesToCommit = [];
|
|
||||||
|
|
||||||
if (options.addAll) {
|
const filePaths = normalizeFilePathList(paths);
|
||||||
await git.add('.');
|
if (filePaths.length === 0) {
|
||||||
} else if (requestedFiles.length > 0) {
|
throw new Error('directory and path are required for stageFile');
|
||||||
filesToCommit = Array.from(new Set(await Promise.all(requestedFiles.map(async (filePath) => {
|
}
|
||||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
validateRepositoryFilePaths(normalizeDirectoryPath(directory), filePaths);
|
||||||
return fileContext.repoPath;
|
|
||||||
}))));
|
|
||||||
|
|
||||||
const status = await git.status();
|
await withGitIndexMutationQueue(directory, async () => {
|
||||||
const fileStatusByPath = new Map(status.files.map((file) => [file.path, file]));
|
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||||
filesToCommit = filesToCommit.filter((filePath) => fileStatusByPath.has(filePath));
|
const repoPaths = Array.from(new Set(await Promise.all(filePaths.map(async (filePath) => {
|
||||||
|
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||||
if (filesToCommit.length === 0) {
|
return fileContext.repoPath;
|
||||||
throw new Error('No selected files are available to commit. Refresh git status and try again.');
|
}))));
|
||||||
}
|
validateRepositoryFilePaths(repoRoot, repoPaths);
|
||||||
|
await git.raw(['add', '--', ...repoPaths]).catch(async (error) => {
|
||||||
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) {
|
|
||||||
const gitErrorText = parseGitErrorText(error);
|
const gitErrorText = parseGitErrorText(error);
|
||||||
const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
|
const isPathspecError = gitErrorText.includes('pathspec') && gitErrorText.includes('did not match any files');
|
||||||
if (!isPathspecError || !commitArgs || commitArgs.length === 0) {
|
if (!isPathspecError) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for deleted/stale selections: commit currently staged changes.
|
// During rapid stage/unstage toggling the optimistic UI can request staging a
|
||||||
result = await git.commit(message);
|
// 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 {
|
export async function unstageFile(directory, filePath) {
|
||||||
success: true,
|
await unstageFiles(directory, [filePath]);
|
||||||
commit: result.commit,
|
}
|
||||||
branch: result.branch,
|
|
||||||
summary: result.summary
|
export async function unstageFiles(directory, paths) {
|
||||||
};
|
if (!directory) {
|
||||||
} catch (error) {
|
throw new Error('directory and path are required for unstageFile');
|
||||||
console.error('Failed to commit:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export async function getBranches(directory) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { resolveBaseRefForLog } from './service.js';
|
import { resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js';
|
||||||
|
|
||||||
describe('resolveBaseRefForLog', () => {
|
describe('resolveBaseRefForLog', () => {
|
||||||
it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
|
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();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,10 @@ export const createWebGitAPI = (): GitAPI => ({
|
|||||||
getGitDiff: gitApiHttp.getGitDiff,
|
getGitDiff: gitApiHttp.getGitDiff,
|
||||||
getGitFileDiff: gitApiHttp.getGitFileDiff,
|
getGitFileDiff: gitApiHttp.getGitFileDiff,
|
||||||
revertGitFile: gitApiHttp.revertGitFile,
|
revertGitFile: gitApiHttp.revertGitFile,
|
||||||
|
stageGitFile: gitApiHttp.stageGitFile,
|
||||||
|
stageGitFiles: gitApiHttp.stageGitFiles,
|
||||||
|
unstageGitFile: gitApiHttp.unstageGitFile,
|
||||||
|
unstageGitFiles: gitApiHttp.unstageGitFiles,
|
||||||
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
|
isLinkedWorktree: gitApiHttp.isLinkedWorktree,
|
||||||
getGitBranches: gitApiHttp.getGitBranches,
|
getGitBranches: gitApiHttp.getGitBranches,
|
||||||
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
|
deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'],
|
||||||
|
|||||||
Reference in New Issue
Block a user