feat(git): float actions beside each diff hunk
Replace the shared hunk menu with compact per-hunk controls over following context. Preserve canonical patch checks, bind controls to rendered rows, and keep EOF actions inside the code column. Validated 19 focused tests, UI type-check, UI/web lint and the web build. Browser checks covered unified and split layouts, themes, and one-line EOF hunks.
This commit is contained in:
@@ -37,7 +37,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
|
||||
import type { DiffViewMode } from '@/components/chat/message/types';
|
||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||
import { PierreDiffViewer } from './PierreDiffViewer';
|
||||
import { PierreDiffViewer, type DiffHunkActions } from './PierreDiffViewer';
|
||||
import { HunkActions, type HunkBusyState, type HunkDiffAction } from './git/HunkActions';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
@@ -48,7 +48,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n/store';
|
||||
import { fileDiffFromPatch, isBinaryPatch, extractHunkPatch, haveMatchingPatchVersions } from '@/lib/diff/patchFileDiff';
|
||||
import { fileDiffFromPatch, isBinaryPatch, extractHunkPatch, haveMatchingPatchVersions, getPatchHunkAnchors } from '@/lib/diff/patchFileDiff';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
||||
@@ -465,6 +465,7 @@ interface InlineDiffViewerProps {
|
||||
diff: DiffData;
|
||||
renderSideBySide: boolean;
|
||||
wrapLines: boolean;
|
||||
hunkActions?: DiffHunkActions;
|
||||
}
|
||||
|
||||
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
||||
@@ -472,6 +473,7 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
||||
diff,
|
||||
renderSideBySide,
|
||||
wrapLines,
|
||||
hunkActions,
|
||||
}) => {
|
||||
const language = React.useMemo(
|
||||
() => getLanguageFromExtension(filePath) || 'text',
|
||||
@@ -503,104 +505,12 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
layout="inline"
|
||||
hunkActions={hunkActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type FileDiffAction = 'stage' | 'unstage' | 'discard';
|
||||
|
||||
interface FileDiffActionsProps {
|
||||
filePath: string;
|
||||
staged: boolean;
|
||||
busyAction: FileDiffAction | null;
|
||||
disabled: boolean;
|
||||
onAction: (action: FileDiffAction) => void;
|
||||
}
|
||||
|
||||
const FileDiffActions = React.memo<FileDiffActionsProps>(({
|
||||
filePath,
|
||||
staged,
|
||||
busyAction,
|
||||
disabled,
|
||||
onAction,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 rounded-full border border-[var(--interactive-border)]/45 bg-[var(--surface-background)]/95 px-1 py-0.5 shadow-sm backdrop-blur-md">
|
||||
{staged ? (
|
||||
<FileDiffActionButton
|
||||
label={t('gitView.changes.unstageFileAria', { path: filePath })}
|
||||
icon="arrow-go-back"
|
||||
loading={busyAction === 'unstage'}
|
||||
disabled={disabled}
|
||||
onClick={() => onAction('unstage')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FileDiffActionButton
|
||||
label={t('gitView.changes.revertFileAria', { path: filePath })}
|
||||
icon="arrow-go-back"
|
||||
loading={busyAction === 'discard'}
|
||||
disabled={disabled}
|
||||
tone="failure"
|
||||
onClick={() => onAction('discard')}
|
||||
/>
|
||||
<FileDiffActionButton
|
||||
label={t('gitView.changes.stageFileAria', { path: filePath })}
|
||||
icon="add"
|
||||
loading={busyAction === 'stage'}
|
||||
disabled={disabled}
|
||||
tone="success"
|
||||
onClick={() => onAction('stage')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface FileDiffActionButtonProps {
|
||||
label: string;
|
||||
icon: 'add' | 'arrow-go-back';
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
tone?: 'failure' | 'success';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const FileDiffActionButton: React.FC<FileDiffActionButtonProps> = ({
|
||||
label,
|
||||
icon,
|
||||
loading,
|
||||
disabled,
|
||||
tone,
|
||||
onClick,
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-6 w-6 rounded-none bg-transparent p-0 text-muted-foreground opacity-70 hover:bg-transparent hover:text-foreground hover:opacity-100',
|
||||
tone === 'failure' && 'text-[var(--status-error)] hover:text-[var(--status-error)]',
|
||||
tone === 'success' && 'text-[var(--status-success)] hover:text-[var(--status-success)]'
|
||||
)}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon name={icon} className={icon === 'add' ? 'size-4' : 'size-3.5'} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
interface MultiFileDiffEntryProps {
|
||||
directory: string;
|
||||
file: FileEntry;
|
||||
@@ -664,7 +574,6 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
const [isFetching, setIsLoading] = React.useState(false);
|
||||
const diffLoadError = comparisonDiff ? (comparisonDiff.status === 'error' ? comparisonDiff.message : null) : localDiffLoadError;
|
||||
const isLoading = comparisonDiff ? comparisonDiff.status === 'loading' : isFetching;
|
||||
const [fileAction, setFileAction] = React.useState<FileDiffAction | null>(null);
|
||||
const [hunkAction, setHunkAction] = React.useState<HunkBusyState>(null);
|
||||
const mutationInFlight = React.useRef(false);
|
||||
const [canonicalPatch, setCanonicalPatch] = React.useState<{ scope: string; patch: string } | null>(null);
|
||||
@@ -757,8 +666,8 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
if (cancelled || runtimeKey !== getRuntimeKey()) return;
|
||||
|
||||
const patch = 'diff' in response && !loadFullFiles ? response.diff : normalPatch;
|
||||
if (hunkEligible && loadFullFiles && patch !== null && (patch.match(/^@@\s/gm)?.length ?? 0) > 1
|
||||
&& ('diff' in response && !haveMatchingPatchVersions(response.diff, patch))) {
|
||||
if (hunkEligible && loadFullFiles && patch !== null && /^@@\s/m.test(patch)
|
||||
&& ('diff' in response && response.diff !== patch && !haveMatchingPatchVersions(response.diff, patch))) {
|
||||
setCanonicalPatch(null);
|
||||
throw new Error(t('diffView.hunk.unavailable'));
|
||||
}
|
||||
@@ -816,41 +725,8 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
setDiffRetryNonce((nonce) => nonce + 1);
|
||||
}, []);
|
||||
|
||||
const handleFileAction = React.useCallback(async (action: FileDiffAction) => {
|
||||
if (!directory || mutationInFlight.current || fileAction !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutationInFlight.current = true;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
setFileAction(action);
|
||||
try {
|
||||
if (action === 'stage') {
|
||||
await git.stageGitFile(directory, file.path);
|
||||
} else if (action === 'unstage') {
|
||||
await git.unstageGitFile(directory, file.path);
|
||||
} else {
|
||||
await git.revertGitFile(directory, file.path, { scope: 'working' });
|
||||
}
|
||||
if (runtimeKey !== getRuntimeKey()) return;
|
||||
invalidatePatch();
|
||||
sessionEvents.requestGitRefresh({ directory, paths: [file.path] });
|
||||
await fetchStatus(directory, git);
|
||||
} catch (error) {
|
||||
const fallbackKey = action === 'unstage'
|
||||
? 'gitView.toast.unstageFileFailed'
|
||||
: action === 'stage'
|
||||
? 'gitView.toast.stageFileFailed'
|
||||
: 'gitView.toast.revertFailed';
|
||||
toast.error(error instanceof Error ? error.message : t(fallbackKey));
|
||||
} finally {
|
||||
mutationInFlight.current = false;
|
||||
setFileAction((current) => (current === action ? null : current));
|
||||
}
|
||||
}, [directory, fetchStatus, file.path, fileAction, git, invalidatePatch, t]);
|
||||
|
||||
const handleHunkAction = React.useCallback(async (hunkIndex: number, action: HunkDiffAction) => {
|
||||
if (!directory || !hunkEligible || isLoading || diffLoadError || mutationInFlight.current || hunkAction !== null || fileAction !== null) {
|
||||
if (!directory || !hunkEligible || isLoading || diffLoadError || mutationInFlight.current || hunkAction !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -887,7 +763,15 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
mutationInFlight.current = false;
|
||||
setHunkAction((current) => (current?.index === hunkIndex && current.action === action ? null : current));
|
||||
}
|
||||
}, [actionPatch, hunkEligible, isLoading, diffLoadError, directory, fetchStatus, file.path, fileAction, git, hunkAction, invalidatePatch, staged, t]);
|
||||
}, [actionPatch, hunkEligible, isLoading, diffLoadError, directory, fetchStatus, file.path, git, hunkAction, invalidatePatch, staged, t]);
|
||||
|
||||
const hunkAnchors = React.useMemo(() => hunkEligible && actionPatch !== null ? getPatchHunkAnchors(actionPatch) : [], [actionPatch, hunkEligible]);
|
||||
const renderHunkActions = React.useCallback((index: number) => (
|
||||
<HunkActions index={index} staged={staged} busyHunk={hunkAction}
|
||||
disabled={isLoading || Boolean(diffLoadError)} onAction={handleHunkAction} />
|
||||
), [diffLoadError, handleHunkAction, hunkAction, isLoading, staged]);
|
||||
const diffHunkActions = React.useMemo<DiffHunkActions | undefined>(() => hunkAnchors.length > 0
|
||||
? { anchors: hunkAnchors, render: renderHunkActions } : undefined, [hunkAnchors, renderHunkActions]);
|
||||
|
||||
return (
|
||||
<div ref={setSectionRef} className="scroll-mt-9 border-b border-[var(--interactive-border)]/40 last:border-b-0">
|
||||
@@ -1053,30 +937,8 @@ export const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
diff={diffData}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
hunkActions={diffHunkActions}
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
|
||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||
{hunkEligible && actionPatch !== null ? (
|
||||
<HunkActions
|
||||
filePath={file.path}
|
||||
patch={actionPatch}
|
||||
staged={staged}
|
||||
busyHunk={hunkAction}
|
||||
disabled={isLoading || Boolean(diffLoadError) || fileAction !== null || hunkAction !== null}
|
||||
onAction={handleHunkAction}
|
||||
/>
|
||||
) : null}
|
||||
{!readOnlyActions ? (
|
||||
<FileDiffActions
|
||||
filePath={file.path}
|
||||
staged={staged}
|
||||
busyAction={fileAction}
|
||||
disabled={fileAction !== null || hunkAction !== null}
|
||||
onAction={handleFileAction}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user