feat(ui): unify change comparisons and compact message actions
Branch comparisons could retain an old base or omit local edits, while Changes and walkthrough selected their sources independently. Share branch and commit selectors across both panels, honor exact refs, include local branch edits, and support first-parent commit diffs with the latest 50 commits. Compact message metadata and move touch actions into a shared sheet. Validated with workspace type-check, lint and build, focused Git and UI tests, and maintainer testing in the app.
This commit is contained in:
@@ -26,6 +26,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { flattenAssistantTextParts, suggestPlanTitleFromText } from '@/lib/messages/messageText';
|
||||
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
|
||||
import { useMessageTTS } from '@/hooks/useMessageTTS';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { TextSelectionMenu } from './TextSelectionMenu';
|
||||
@@ -470,7 +471,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
alwaysShowActions?: boolean;
|
||||
hasTouchInput?: boolean;
|
||||
hasTextContent?: boolean;
|
||||
onCopyMessage?: () => void;
|
||||
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
|
||||
copiedMessage?: boolean;
|
||||
onShowPopup: (content: ToolPopupContent) => void;
|
||||
agentMention?: AgentMentionInfo;
|
||||
@@ -573,6 +574,51 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
);
|
||||
|
||||
const effectiveOnFork = chatSurfaceMode === 'mini-chat' ? undefined : onFork;
|
||||
const [userActionSheetOpen, setUserActionSheetOpen] = React.useState(false);
|
||||
const userSheetActions = React.useMemo(() => {
|
||||
const actions: Array<{ id: string; label: string; icon: React.ReactNode; disabled?: boolean; onSelect: () => void }> = [];
|
||||
if (canCopyMessage && hasCopyableText && onCopyMessage) {
|
||||
actions.push({
|
||||
id: 'copy',
|
||||
label: t('chat.messageBody.actions.copyMessage'),
|
||||
icon: <Icon name="file-copy" className="h-4 w-4" />,
|
||||
// The sheet closes on tap, so the button's own tick has nowhere
|
||||
// to land — say it with a toast instead.
|
||||
onSelect: () => {
|
||||
void (async () => {
|
||||
const copied = await onCopyMessage();
|
||||
if (copied !== false) toast.success(t('chat.messageBody.toast.copied'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
}
|
||||
if (onToggleContextPin && hasCopyableText) {
|
||||
actions.push({
|
||||
id: 'pin-context',
|
||||
label: t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext'),
|
||||
icon: <Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-4 w-4" />,
|
||||
disabled: contextPinPending,
|
||||
onSelect: () => { onToggleContextPin(); },
|
||||
});
|
||||
}
|
||||
if (effectiveOnFork) {
|
||||
actions.push({
|
||||
id: 'fork',
|
||||
label: t('chat.messageBody.actions.fork'),
|
||||
icon: <Icon name="git-branch" className="h-4 w-4" />,
|
||||
onSelect: () => { effectiveOnFork(); },
|
||||
});
|
||||
}
|
||||
if (onRevert) {
|
||||
actions.push({
|
||||
id: 'revert',
|
||||
label: t('chat.messageBody.actions.revert'),
|
||||
icon: <Icon name="arrow-go-back" className="h-4 w-4" />,
|
||||
onSelect: () => { onRevert(); },
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}, [canCopyMessage, contextPinPending, contextPinned, effectiveOnFork, hasCopyableText, onCopyMessage, onRevert, onToggleContextPin, t]);
|
||||
const timestamp = React.useMemo(() => {
|
||||
void locale;
|
||||
if (typeof messageCreatedAt !== 'number' || messageCreatedAt <= 0) return null;
|
||||
@@ -607,7 +653,8 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
: 'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100 group-hover/user-shell:pointer-events-auto group-hover/user-shell:opacity-100'
|
||||
)}
|
||||
>
|
||||
{timestamp ? (
|
||||
{/* Touch reads the time in the actions sheet instead — see below. */}
|
||||
{timestamp && !alwaysShowActions ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
@@ -615,106 +662,161 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
|
||||
aria-label={`Message time: ${timestamp}`}
|
||||
>
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
<span className="message-footer__label">{timestamp}</span>
|
||||
<span>{timestamp}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{timestamp}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onRevert && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Touch has no hover, so the row would stand open under every
|
||||
message. One button and a labelled sheet instead — the same
|
||||
shape the assistant footer uses. */}
|
||||
{alwaysShowActions ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.revertAria')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRevert();
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-go-back" className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.revert')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{effectiveOnFork && (
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.moreActions')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setUserActionSheetOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="more" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<MobileOverlayPanel
|
||||
open={userActionSheetOpen}
|
||||
onClose={() => setUserActionSheetOpen(false)}
|
||||
title={t('chat.messageBody.actions.moreActions')}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{timestamp ? (
|
||||
<div className="mb-1 flex items-center gap-3 border-b border-border/60 px-3 pb-2 text-muted-foreground">
|
||||
<Icon name="time" className="h-4 w-4" />
|
||||
<span className="typography-ui-label">{timestamp}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{userSheetActions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
className="flex min-h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-foreground transition-colors active:bg-interactive-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
onClick={() => {
|
||||
setUserActionSheetOpen(false);
|
||||
action.onSelect();
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="text-muted-foreground">{action.icon}</span>
|
||||
<span className="typography-ui-label">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{onRevert && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.forkAria')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
effectiveOnFork();
|
||||
}}
|
||||
>
|
||||
<Icon name="git-branch" className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onToggleContextPin && hasCopyableText && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
|
||||
)}
|
||||
disabled={contextPinPending}
|
||||
aria-pressed={contextPinned}
|
||||
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
|
||||
>
|
||||
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCopyMessage && hasCopyableText && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.copyMessageAria')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleCopyButtonClick}
|
||||
onFocus={() => setCopyHintVisible(true)}
|
||||
onBlur={() => {
|
||||
if (!isMessageCopied) {
|
||||
setCopyHintVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isMessageCopied ? (
|
||||
<Icon name="check" className="h-3 w-3 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<Icon name="file-copy" className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyMessage')}</TooltipContent>
|
||||
</Tooltip>
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.revertAria')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRevert();
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-go-back" className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.revert')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{effectiveOnFork && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.forkAria')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
effectiveOnFork();
|
||||
}}
|
||||
>
|
||||
<Icon name="git-branch" className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.fork')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onToggleContextPin && hasCopyableText && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
|
||||
)}
|
||||
disabled={contextPinPending}
|
||||
aria-pressed={contextPinned}
|
||||
aria-label={t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onToggleContextPin(); }}
|
||||
>
|
||||
<Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canCopyMessage && hasCopyableText && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
|
||||
className="h-6 w-6 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.copyMessageAria')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleCopyButtonClick}
|
||||
onFocus={() => setCopyHintVisible(true)}
|
||||
onBlur={() => {
|
||||
if (!isMessageCopied) {
|
||||
setCopyHintVisible(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isMessageCopied ? (
|
||||
<Icon name="check" className="h-3 w-3 text-[color:var(--status-success)]" />
|
||||
) : (
|
||||
<Icon name="file-copy" className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyMessage')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
@@ -981,7 +1083,7 @@ const AssistantMessageActionButtons = React.memo(({
|
||||
size="icon"
|
||||
data-visible={copyHintVisible || isMessageCopied ? 'true' : undefined}
|
||||
className={cn(
|
||||
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
'h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
!hasCopyableText && 'opacity-50'
|
||||
)}
|
||||
disabled={!hasCopyableText}
|
||||
@@ -1021,7 +1123,7 @@ const AssistantMessageActionButtons = React.memo(({
|
||||
variant="ghost"
|
||||
disabled={isTransferringReview || !hasCopyableText}
|
||||
className={cn(
|
||||
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
'h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
(!hasCopyableText || isTransferringReview) && 'opacity-50'
|
||||
)}
|
||||
aria-label={reviewTransferAction.ariaLabel}
|
||||
@@ -1031,9 +1133,9 @@ const AssistantMessageActionButtons = React.memo(({
|
||||
}}
|
||||
>
|
||||
{isTransferringReview ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon name="arrow-left-right" className="h-4 w-4" />
|
||||
<Icon name="arrow-left-right" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -1048,7 +1150,7 @@ const AssistantMessageActionButtons = React.memo(({
|
||||
variant="ghost"
|
||||
disabled={isSharing || !hasCopyableText}
|
||||
className={cn(
|
||||
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
'h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
(!hasCopyableText || isSharing) && 'opacity-50'
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
@@ -1057,9 +1159,9 @@ const AssistantMessageActionButtons = React.memo(({
|
||||
}}
|
||||
>
|
||||
{isSharing ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon name="image-download" className="h-4 w-4" />
|
||||
<Icon name="image-download" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -1073,7 +1175,7 @@ const AssistantMessageActionButtons = React.memo(({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-8 w-8 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
'h-7 w-7 bg-transparent hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
isTTSPlaying ? 'text-green-500' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
aria-label={isTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud')}
|
||||
@@ -1375,9 +1477,10 @@ const AssistantMessageBody = React.memo(({
|
||||
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
|
||||
|
||||
const handleForkClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
// Optional event: the footer's action sheet calls this without one.
|
||||
(event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
if (!assistantPlanText.trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -1438,9 +1541,10 @@ const AssistantMessageBody = React.memo(({
|
||||
);
|
||||
|
||||
const handleSaveAsPlanClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
// Optional event: the footer's action sheet calls this without one.
|
||||
(event?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
if (!assistantPlanText.trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -2059,9 +2163,98 @@ const AssistantMessageBody = React.memo(({
|
||||
return formatted.length > 0 ? formatted : null;
|
||||
}, [messageCompletedAt, messageCreatedAt, timeFormatPreference, locale]);
|
||||
|
||||
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
|
||||
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums';
|
||||
|
||||
// Touch surfaces have no hover, so the footer would have to show every
|
||||
// action at all times — four 36px targets that pushed the metadata onto its
|
||||
// own lines. Collapse them into one "more" button and a labelled sheet, the
|
||||
// same one the composer uses to pick a model. The buttons below stay the
|
||||
// pointer path; these rows call the same handlers, minus the transient
|
||||
// copied/sharing states that only make sense on a button that stays put.
|
||||
const [actionSheetOpen, setActionSheetOpen] = React.useState(false);
|
||||
const { isPlaying: isFooterTTSPlaying, play: playFooterTTS, stop: stopFooterTTS } = useMessageTTS();
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode;
|
||||
|
||||
const footerSheetActions = React.useMemo(() => {
|
||||
const actions: Array<{ id: string; label: string; icon: React.ReactNode; disabled?: boolean; onSelect: () => void }> = [];
|
||||
if (onCopyMessage) {
|
||||
actions.push({
|
||||
id: 'copy',
|
||||
label: t('chat.messageBody.actions.copyAnswer'),
|
||||
icon: <Icon name="file-copy" className="h-4 w-4" />,
|
||||
disabled: !hasCopyableText,
|
||||
// The sheet closes on tap, so the button's own "copied" tick has
|
||||
// nowhere to land — say it with a toast instead.
|
||||
onSelect: () => {
|
||||
void (async () => {
|
||||
const copied = await onCopyMessage();
|
||||
if (copied !== false) toast.success(t('chat.messageBody.toast.copied'));
|
||||
})();
|
||||
},
|
||||
});
|
||||
}
|
||||
if (reviewTransferAction && !isMiniChatSurface) {
|
||||
actions.push({
|
||||
id: 'review-transfer',
|
||||
label: reviewTransferAction.tooltip,
|
||||
icon: <Icon name="arrow-left-right" className="h-3.5 w-3.5" />,
|
||||
disabled: !hasCopyableText,
|
||||
onSelect: () => { void reviewTransferAction.onClick(); },
|
||||
});
|
||||
}
|
||||
if (!isMiniChatSurface) {
|
||||
actions.push({
|
||||
id: 'share-image',
|
||||
label: t('chat.messageBody.actions.saveAsImage'),
|
||||
icon: <Icon name="image-download" className="h-3.5 w-3.5" />,
|
||||
disabled: !hasCopyableText,
|
||||
onSelect: () => { void shareMessageAsImage(); },
|
||||
});
|
||||
}
|
||||
if (!isMiniChatSurface && showMessageTTSButtons && hasCopyableText) {
|
||||
actions.push({
|
||||
id: 'tts',
|
||||
label: isFooterTTSPlaying ? t('chat.messageBody.tts.stopSpeaking') : t('chat.messageBody.tts.readAloud'),
|
||||
icon: <Icon name={isFooterTTSPlaying ? 'stop' : 'volume-up'} className="h-4 w-4" />,
|
||||
onSelect: () => {
|
||||
if (isFooterTTSPlaying) {
|
||||
stopFooterTTS();
|
||||
return;
|
||||
}
|
||||
if (assistantPlanText.trim()) void playFooterTTS(assistantPlanText);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (canUseProjectPlanActions && !isReviewSessionView) {
|
||||
actions.push({
|
||||
id: 'save-as-plan',
|
||||
label: t('chat.messageBody.actions.saveAsPlan'),
|
||||
icon: <Icon name="booklet" className="h-3.5 w-3.5" />,
|
||||
disabled: !hasCopyableText || !currentProjectRef,
|
||||
onSelect: () => { handleSaveAsPlanClick(); },
|
||||
});
|
||||
}
|
||||
if (onToggleContextPin && hasCopyableText) {
|
||||
actions.push({
|
||||
id: 'pin-context',
|
||||
label: t(contextPinned ? 'chat.messageBody.actions.unpinContext' : 'chat.messageBody.actions.pinContext'),
|
||||
icon: <Icon name={contextPinned ? 'pushpin-2-fill' : 'pushpin-2'} className="h-4 w-4" />,
|
||||
disabled: contextPinPending,
|
||||
onSelect: () => { onToggleContextPin(); },
|
||||
});
|
||||
}
|
||||
if (!isMiniChatSurface && !isReviewSessionView) {
|
||||
actions.push({
|
||||
id: 'fork',
|
||||
label: t('chat.messageBody.actions.startNewSession'),
|
||||
icon: <Icon name="chat-new" className="h-3.5 w-3.5" />,
|
||||
onSelect: () => { handleForkClick(); },
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
}, [assistantPlanText, canUseProjectPlanActions, contextPinPending, contextPinned, currentProjectRef, handleForkClick, handleSaveAsPlanClick, hasCopyableText, isFooterTTSPlaying, isMiniChatSurface, isReviewSessionView, onCopyMessage, onToggleContextPin, playFooterTTS, reviewTransferAction, shareMessageAsImage, showMessageTTSButtons, stopFooterTTS, t]);
|
||||
|
||||
const finalTurnActionButtons = (
|
||||
<>
|
||||
{canOpenMessagePreview && messagePreviewUrl ? (
|
||||
@@ -2071,7 +2264,7 @@ const AssistantMessageBody = React.memo(({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
className="h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.openPreviewAria')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => {
|
||||
@@ -2083,7 +2276,7 @@ const AssistantMessageBody = React.memo(({
|
||||
openContextPreview(directory, messagePreviewUrl);
|
||||
}}
|
||||
>
|
||||
<Icon name="global" className="h-4 w-4" />
|
||||
<Icon name="global" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
|
||||
@@ -2098,13 +2291,13 @@ const AssistantMessageBody = React.memo(({
|
||||
variant="ghost"
|
||||
disabled={!hasCopyableText || !currentProjectRef}
|
||||
className={cn(
|
||||
'h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
'h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
(!hasCopyableText || !currentProjectRef) && 'opacity-50'
|
||||
)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleSaveAsPlanClick}
|
||||
>
|
||||
<Icon name="booklet" className="h-4 w-4" />
|
||||
<Icon name="booklet" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
|
||||
@@ -2118,7 +2311,7 @@ const AssistantMessageBody = React.memo(({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-8 w-8 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
'h-7 w-7 bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
contextPinned ? 'text-[color:var(--status-info)]' : 'text-muted-foreground',
|
||||
)}
|
||||
disabled={contextPinPending}
|
||||
@@ -2139,11 +2332,11 @@ const AssistantMessageBody = React.memo(({
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
className="h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleForkClick}
|
||||
>
|
||||
<Icon name="chat-new" className="h-4 w-4" />
|
||||
<Icon name="chat-new" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
|
||||
@@ -2155,11 +2348,11 @@ const AssistantMessageBody = React.memo(({
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
className="h-7 w-7 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={handleForkMultiRunClick}
|
||||
>
|
||||
<ArrowsMerge className="h-4 w-4" />
|
||||
<ArrowsMerge className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewMultiRun')}</TooltipContent>
|
||||
@@ -2239,93 +2432,146 @@ const AssistantMessageBody = React.memo(({
|
||||
)}
|
||||
{shouldShowTurnFooter && (
|
||||
<div
|
||||
className="mt-2 mb-1 flex flex-wrap items-center justify-start gap-x-3 gap-y-1.5"
|
||||
className="mt-2 mb-1 flex flex-col gap-y-1.5"
|
||||
style={MESSAGE_FOOTER_CONTAINER_STYLE}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1 text-sm text-muted-foreground/60">
|
||||
{footerModelName ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{footerHasLogo && footerLogoSrc ? (
|
||||
<img
|
||||
src={footerLogoSrc}
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 flex-shrink-0"
|
||||
style={{
|
||||
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
|
||||
}}
|
||||
onError={handleFooterLogoError}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
name="brain-ai-3"
|
||||
className="h-3.5 w-3.5 flex-shrink-0"
|
||||
style={{ color: `var(${getAgentColor(footerAgentName).var})` }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{footerModelName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="brain-ai-3" className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="message-footer__label">
|
||||
{footerVariant[0].toLowerCase() + footerVariant.slice(1)}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{footerAgentName ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon name="ai-agent" className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="message-footer__label">{footerAgentName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{turnDurationText ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||
<Icon name="hourglass" className="h-3.5 w-3.5" />
|
||||
<span className="message-footer__label">{turnDurationText}</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* One line, always. The facts are ordered by how much they
|
||||
matter, and the CSS drops them from the tail as the row
|
||||
narrows: first the time, then the agent, then the thinking
|
||||
effort. Model and duration never leave — the model only
|
||||
truncates once those two alone stop fitting. */}
|
||||
<div className="message-footer__facts flex-1 whitespace-nowrap text-sm text-muted-foreground/60">
|
||||
<span className="message-footer__facts-core">
|
||||
{footerModelName ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{footerHasLogo && footerLogoSrc ? (
|
||||
<img
|
||||
src={footerLogoSrc}
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 flex-shrink-0"
|
||||
style={{
|
||||
filter: isDarkTheme ? 'brightness(0.9) contrast(1.1) invert(1)' : 'brightness(0.9) contrast(1.1)',
|
||||
}}
|
||||
onError={handleFooterLogoError}
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
name="brain-ai-3"
|
||||
className="h-3.5 w-3.5 flex-shrink-0"
|
||||
style={{ color: `var(${getAgentColor(footerAgentName).var})` }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{footerModelName}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{turnDurationText}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{footerTimestamp ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
) : null}
|
||||
</span>
|
||||
{/* Thinking effort and agent drop out from the tail — the
|
||||
agent first — when the row runs short. */}
|
||||
<span className="message-footer__facts-optional">
|
||||
{footerVariant && !['default', 'none'].includes(footerVariant.toLowerCase()) ? (
|
||||
<span className="message-footer__fact">
|
||||
<span className="opacity-60" aria-hidden>·</span>
|
||||
{footerVariant[0].toLowerCase() + footerVariant.slice(1)}
|
||||
</span>
|
||||
) : null}
|
||||
{footerAgentName ? (
|
||||
<span className="message-footer__fact">
|
||||
<span className="opacity-60" aria-hidden>·</span>
|
||||
{footerAgentName}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{turnDurationText ? (
|
||||
<span className="message-footer__facts-duration message-footer__fact tabular-nums">
|
||||
{footerModelName ? <span className="opacity-60" aria-hidden>·</span> : null}
|
||||
{turnDurationText}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Pointer surfaces keep the timestamp inline (it goes first
|
||||
when space runs out); touch reads it in the actions
|
||||
sheet, where nothing can push it off the row. */}
|
||||
{footerTimestamp && !(alwaysShowMessageActions || isTouchContext) ? (
|
||||
<span className="message-footer__facts-optional message-footer__facts-optional--drops-first">
|
||||
<span
|
||||
className={footerTimestampClassName}
|
||||
className={cn(footerTimestampClassName, 'message-footer__fact')}
|
||||
aria-label={`Message time: ${footerTimestamp}`}
|
||||
>
|
||||
<Icon name="time" className="h-3.5 w-3.5" />
|
||||
<span className="message-footer__label">{footerTimestamp}</span>
|
||||
<span className="opacity-60" aria-hidden>·</span>
|
||||
{footerTimestamp}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{footerTimestamp}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||
) : null}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilePills
|
||||
files={turnGroupingContext?.changedFiles}
|
||||
isInteractive={turnGroupingContext?.isLatestTurn === true}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1.5',
|
||||
alwaysShowMessageActions || isTouchContext
|
||||
? undefined
|
||||
: 'pointer-events-none opacity-0 transition-opacity duration-150 focus-within:pointer-events-auto focus-within:opacity-100 group-hover/message:pointer-events-auto group-hover/message:opacity-100'
|
||||
)}
|
||||
data-message-action-group="true"
|
||||
{alwaysShowMessageActions || isTouchContext ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground bg-transparent hover:text-foreground hover:!bg-transparent active:!bg-transparent focus-visible:!bg-transparent focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('chat.messageBody.actions.moreActions')}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setActionSheetOpen(true);
|
||||
}}
|
||||
data-message-action-group="true"
|
||||
>
|
||||
<Icon name="more" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5 pointer-events-none opacity-0 transition-opacity duration-150 focus-within:pointer-events-auto focus-within:opacity-100 group-hover/message:pointer-events-auto group-hover/message:opacity-100"
|
||||
data-message-action-group="true"
|
||||
>
|
||||
{messageActionButtons}
|
||||
{finalTurnActionButtons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Changed files keep their own line: they are a list that
|
||||
grows, not a fact about the run. */}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||
<TurnChangedFilePills
|
||||
files={turnGroupingContext?.changedFiles}
|
||||
isInteractive={turnGroupingContext?.isLatestTurn === true}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<MobileOverlayPanel
|
||||
open={actionSheetOpen}
|
||||
onClose={() => setActionSheetOpen(false)}
|
||||
title={t('chat.messageBody.actions.moreActions')}
|
||||
>
|
||||
{messageActionButtons}
|
||||
{finalTurnActionButtons}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{/* The row drops the timestamp first on a narrow screen,
|
||||
so the sheet is where it is always readable. */}
|
||||
{footerTimestamp ? (
|
||||
<div className="mb-1 flex items-center gap-3 border-b border-border/60 px-3 pb-2 text-muted-foreground">
|
||||
<Icon name="time" className="h-4 w-4" />
|
||||
<span className="typography-ui-label">{footerTimestamp}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{footerSheetActions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
className="flex min-h-11 w-full items-center gap-3 rounded-lg px-3 text-left text-foreground transition-colors active:bg-interactive-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
onClick={() => {
|
||||
setActionSheetOpen(false);
|
||||
action.onSelect();
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="text-muted-foreground">{action.icon}</span>
|
||||
<span className="typography-ui-label">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useUIStore, type PendingDiffScope } from '@/stores/useUIStore';
|
||||
import { useCommitComparison } from '@/hooks/useCommitComparison';
|
||||
import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory';
|
||||
import { NestedRepoPicker } from '@/components/views/git/NestedRepoPicker';
|
||||
import { BranchComparisonSelector } from '@/components/views/git/BranchComparisonSelector';
|
||||
import { branchRefLabel } from '@/components/views/git/baseBranch';
|
||||
import { useGitStore, useGitStatus, useIsGitRepo, useGitLoadingStatus } from '@/stores/useGitStore';
|
||||
import { useGitBaseBranchStore, gitBaseBranchEntryKey } from '@/stores/useGitBaseBranchStore';
|
||||
import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
|
||||
import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase';
|
||||
import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeDefinitelyUnavailable, useRangeKeyedCache, useBoundedDirectoryRetry } from './branchDiffScope';
|
||||
import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
|
||||
import { getGitRangeDiff, getGitRangeFiles, getCommitFiles, getGitCommitDiff } from '@/lib/gitApi';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
|
||||
import type { GitStatus, GitRangeFileEntry, CommitFileEntry } from '@/lib/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -85,7 +89,7 @@ type DiffData = {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
contextMode?: DiffContextMode;
|
||||
};
|
||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn' | 'branch';
|
||||
type DiffScope = 'all' | PendingDiffScope;
|
||||
|
||||
type TurnSnapshotDiff = {
|
||||
file?: string;
|
||||
@@ -97,17 +101,16 @@ type TurnSnapshotDiff = {
|
||||
deletions?: number;
|
||||
};
|
||||
|
||||
/** Reservation slot for a branch range diff while its fetch is in flight. */
|
||||
const EMPTY_BRANCH_DIFF_PLACEHOLDER: DiffData = {
|
||||
original: '',
|
||||
modified: '',
|
||||
isBinary: false,
|
||||
contextMode: 'patch',
|
||||
};
|
||||
type ComparisonDiffResult =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; data: DiffData }
|
||||
| { status: 'error'; message: string };
|
||||
const EMPTY_COMPARISON_DIFF: ComparisonDiffResult = { status: 'loading' };
|
||||
|
||||
/** Bounded retries for branch metadata in the context diff panel (see effect). */
|
||||
const BRANCH_METADATA_MAX_ATTEMPTS = 3;
|
||||
|
||||
|
||||
const BinaryDiffPlaceholder = React.memo(() => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -247,13 +250,15 @@ const formatDiffTotals = (
|
||||
};
|
||||
|
||||
interface ChangeScopeSelectorProps {
|
||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>;
|
||||
scope: PendingDiffScope;
|
||||
workingCount: number;
|
||||
stagedCount: number;
|
||||
turnCount: number;
|
||||
branchCount: number | null;
|
||||
commitCount: number | null;
|
||||
showCommitOption: boolean;
|
||||
showBranchOption: boolean;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
|
||||
onScopeChange?: (scope: PendingDiffScope) => void;
|
||||
}
|
||||
|
||||
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
@@ -262,19 +267,21 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
stagedCount,
|
||||
turnCount,
|
||||
branchCount,
|
||||
commitCount,
|
||||
showCommitOption,
|
||||
showBranchOption,
|
||||
onScopeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : workingCount;
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : scope === 'branch' ? (branchCount ?? 0) : scope === 'commit' ? (commitCount ?? 0) : workingCount;
|
||||
const currentLabel = scope === 'staged'
|
||||
? t('diffView.scope.staged')
|
||||
: scope === 'turn'
|
||||
? t('diffView.scope.lastTurn')
|
||||
: scope === 'branch'
|
||||
? t('diffView.scope.branch')
|
||||
: t('diffView.scope.changed');
|
||||
: scope === 'commit' ? t('commitComparison.mode') : t('diffView.scope.changed');
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -294,7 +301,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch') {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' || value === 'commit') {
|
||||
onScopeChange?.(value);
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -326,6 +333,14 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
) : null}
|
||||
{showCommitOption && (
|
||||
<DropdownMenuRadioItem value="commit">
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span>{t('commitComparison.mode')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{commitCount ?? '…'}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -605,7 +620,9 @@ interface MultiFileDiffEntryProps {
|
||||
staged?: boolean;
|
||||
loadFullFiles?: boolean;
|
||||
initialDiffData?: DiffData | null;
|
||||
/** Hide stage/unstage/revert actions (read-only scopes like branch diffs). */
|
||||
comparisonDiff?: ComparisonDiffResult;
|
||||
onRetryComparisonDiff?: () => void;
|
||||
/** Hide stage/unstage/revert actions for branch and commit comparisons. */
|
||||
readOnlyActions?: boolean;
|
||||
}
|
||||
|
||||
@@ -626,6 +643,8 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
staged = false,
|
||||
loadFullFiles = false,
|
||||
initialDiffData = null,
|
||||
comparisonDiff,
|
||||
onRetryComparisonDiff,
|
||||
readOnlyActions = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -640,8 +659,10 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout);
|
||||
|
||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [localDiffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||
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 [forceRenderLarge, setForceRenderLarge] = React.useState(false);
|
||||
const [localDiffData, setLocalDiffData] = React.useState<DiffData | null>(null);
|
||||
@@ -655,12 +676,13 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
const fileStatusKey = `${file.index}:${file.working_dir}:${file.insertions}:${file.deletions}`;
|
||||
|
||||
const diffData = React.useMemo<DiffData | null>(() => {
|
||||
if (comparisonDiff) return comparisonDiff.status === 'ready' ? comparisonDiff.data : null;
|
||||
if (initialDiffData) return initialDiffData;
|
||||
if (staged) return stagedDiffData;
|
||||
if (localDiffData) return localDiffData;
|
||||
if (!cachedDiff) return null;
|
||||
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary, contextMode: 'full' };
|
||||
}, [cachedDiff, initialDiffData, localDiffData, staged, stagedDiffData]);
|
||||
}, [comparisonDiff, cachedDiff, initialDiffData, localDiffData, staged, stagedDiffData]);
|
||||
|
||||
const diffDataMatchesContextMode = diffData?.contextMode === desiredContextMode;
|
||||
|
||||
@@ -690,7 +712,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded || !isMounted) return;
|
||||
if (!directory || initialDiffData || (diffData && diffDataMatchesContextMode)) {
|
||||
if (!directory || comparisonDiff || initialDiffData || (diffData && diffDataMatchesContextMode)) {
|
||||
lastDiffRequestRef.current = null;
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -754,7 +776,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
lastDiffRequestRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
|
||||
}, [comparisonDiff, desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
handleOpenChange(!isExpanded);
|
||||
@@ -917,7 +939,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
<button
|
||||
type="button"
|
||||
className="typography-ui-label text-primary hover:underline"
|
||||
onClick={() => setDiffRetryNonce((nonce) => nonce + 1)}
|
||||
onClick={() => comparisonDiff ? onRetryComparisonDiff?.() : setDiffRetryNonce((nonce) => nonce + 1)}
|
||||
>
|
||||
{t('diffView.actions.retry')}
|
||||
</button>
|
||||
@@ -981,7 +1003,7 @@ interface DiffViewProps {
|
||||
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
||||
showOpenInEditorAction?: boolean;
|
||||
diffScope?: DiffScope;
|
||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn' | 'branch'>) => void;
|
||||
onDiffScopeChange?: (scope: PendingDiffScope) => void;
|
||||
targetFilePath?: string | null;
|
||||
/** Render diff content flush with the container edges (no outer padding). */
|
||||
flushContent?: boolean;
|
||||
@@ -1051,7 +1073,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const activeDiffStaged = forcedStaged ?? displayFileStaged;
|
||||
|
||||
const isMobileLayout = isMobile || screenWidth <= 768;
|
||||
const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
||||
const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && activeDiffScope !== 'commit' && !isMobileLayout && !isVSCodeRuntime();
|
||||
// Same runtime and width rules as the rail surface: no point offering an
|
||||
// entry point to a surface that cannot open here.
|
||||
const showWalkthroughAction = activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
||||
@@ -1139,6 +1161,37 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
// ----- Branch scope (all changes on this branch vs its base) -----
|
||||
const currentBranch = status?.current ?? null;
|
||||
const commitComparison = useCommitComparison(effectiveDirectory ?? null, currentBranch, activeDiffScope === 'commit' && !isVSCodeRuntime());
|
||||
const selectedCommitHash = commitComparison.selectedCommit?.hash ?? null;
|
||||
const commitQueryKey = activeDiffScope === 'commit' && effectiveDirectory && selectedCommitHash
|
||||
? JSON.stringify([getRuntimeKey(), effectiveDirectory, selectedCommitHash])
|
||||
: null;
|
||||
const [commitFilesResult, setCommitFilesResult] = React.useState<
|
||||
{ key: string; files: CommitFileEntry[] } | { key: string; error: string } | null
|
||||
>(null);
|
||||
const [commitFilesRetry, setCommitFilesRetry] = React.useState(0);
|
||||
const currentCommitFiles = commitFilesResult?.key === commitQueryKey ? commitFilesResult : null;
|
||||
const commitFiles = currentCommitFiles && 'files' in currentCommitFiles ? currentCommitFiles.files : null;
|
||||
const commitFilesError = currentCommitFiles && 'error' in currentCommitFiles ? currentCommitFiles.error : null;
|
||||
const commitFilesByPath = React.useMemo(() => new Map((commitFiles ?? []).map((file) => [file.path, file])), [commitFiles]);
|
||||
React.useEffect(() => {
|
||||
if (!commitQueryKey || !effectiveDirectory || !selectedCommitHash) return;
|
||||
let cancelled = false;
|
||||
setCommitFilesResult(null);
|
||||
getCommitFiles(effectiveDirectory, selectedCommitHash)
|
||||
.then(({ files }) => { if (!cancelled) setCommitFilesResult({ key: commitQueryKey, files }); })
|
||||
.catch((error) => {
|
||||
if (!cancelled) setCommitFilesResult({ key: commitQueryKey, error: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [commitFilesRetry, commitQueryKey, effectiveDirectory, selectedCommitHash, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeDiffScope === 'commit' && isVSCodeRuntime()) {
|
||||
setActiveDiffScope('working');
|
||||
onDiffScopeChange?.('working');
|
||||
}
|
||||
}, [activeDiffScope, onDiffScopeChange]);
|
||||
const branches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.branches ?? null : null));
|
||||
const isLoadingBranches = useGitStore((state) => (effectiveDirectory ? state.directories.get(effectiveDirectory)?.isLoadingBranches ?? false : false));
|
||||
|
||||
@@ -1192,22 +1245,12 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
);
|
||||
|
||||
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
|
||||
// Subscribe to the overrides map directly: `getOverride` reads `get()`
|
||||
// imperatively, so a memo over it never recomputes when the store changes
|
||||
// and a freshly picked base would be invisible until an unrelated rerender.
|
||||
// The key includes the current branch: a base picked for one feature branch
|
||||
// is not an answer for another branch of the same repository.
|
||||
const baseOverride = useGitBaseBranchStore(
|
||||
React.useCallback(
|
||||
(state) => (effectiveDirectory && currentBranch
|
||||
? state.overrides[gitBaseBranchEntryKey(effectiveDirectory, currentBranch)] ?? null
|
||||
: null),
|
||||
[currentBranch, effectiveDirectory]
|
||||
)
|
||||
const { base: branchBase, resolved: isBranchBaseResolved, revision: branchRevision } = useBranchComparisonBase(
|
||||
effectiveDirectory ?? null,
|
||||
currentBranch,
|
||||
showBranchOption && activeDiffScope === 'branch',
|
||||
);
|
||||
const [detectedBranchBase, setDetectedBranchBase] = React.useState<string | null>(null);
|
||||
const [isBranchBaseResolved, setIsBranchBaseResolved] = React.useState(false);
|
||||
const [basePickerSearch, setBasePickerSearch] = React.useState('');
|
||||
const [comparisonRetryRevision, setComparisonRetryRevision] = React.useState(0);
|
||||
|
||||
// A context tab persists its scope across branch checkouts and runtime
|
||||
// switches. When the Branch scope is CONFIRMED unavailable (checked out the
|
||||
@@ -1228,97 +1271,100 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}
|
||||
}, [activeDiffScope, branchScopeDefinitelyUnavailable, onDiffScopeChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!showBranchOption || !effectiveDirectory || !currentBranch) {
|
||||
setDetectedBranchBase(null);
|
||||
setIsBranchBaseResolved(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsBranchBaseResolved(false);
|
||||
getBranchBase(effectiveDirectory, currentBranch)
|
||||
.then((result) => {
|
||||
if (!cancelled) setDetectedBranchBase(result.base);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDetectedBranchBase(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsBranchBaseResolved(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentBranch, effectiveDirectory, showBranchOption]);
|
||||
|
||||
// Explicit user choice outranks the detected source; both are real answers
|
||||
// from git or the user — never a main/master guess.
|
||||
const branchBase = baseOverride ?? detectedBranchBase;
|
||||
|
||||
const [branchFiles, setBranchFiles] = React.useState<GitRangeFileEntry[] | null>(null);
|
||||
const [branchFilesError, setBranchFilesError] = React.useState<string | null>(null);
|
||||
const branchQueryKey = effectiveDirectory && currentBranch && branchBase
|
||||
? JSON.stringify([getRuntimeKey(), effectiveDirectory, branchBase, currentBranch])
|
||||
: null;
|
||||
const [branchFilesResult, setBranchFilesResult] = React.useState<
|
||||
{ key: string; files: GitRangeFileEntry[] } | { key: string; error: string } | null
|
||||
>(null);
|
||||
const currentBranchFilesResult = branchFilesResult?.key === branchQueryKey ? branchFilesResult : null;
|
||||
const branchFiles = currentBranchFilesResult && 'files' in currentBranchFilesResult ? currentBranchFilesResult.files : null;
|
||||
const branchFilesError = currentBranchFilesResult && 'error' in currentBranchFilesResult ? currentBranchFilesResult.error : null;
|
||||
|
||||
// Shared by the scope/base effect and the error-state Retry button; the
|
||||
// fetch id discards completions from a superseded run (base or head
|
||||
// changed, or an earlier retry is still in flight).
|
||||
const branchFilesFetchIdRef = React.useRef(0);
|
||||
const reloadBranchFiles = React.useCallback(() => {
|
||||
if (!effectiveDirectory || !currentBranch || !branchBase) return;
|
||||
if (!effectiveDirectory || !currentBranch || !branchBase || !branchQueryKey) return;
|
||||
const fetchId = branchFilesFetchIdRef.current + 1;
|
||||
branchFilesFetchIdRef.current = fetchId;
|
||||
setBranchFiles(null);
|
||||
setBranchFilesError(null);
|
||||
getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch })
|
||||
setBranchFilesResult((previous) => previous?.key === branchQueryKey && 'files' in previous ? previous : null);
|
||||
getGitRangeFiles(effectiveDirectory, { base: branchBase, head: currentBranch, includeWorkingTree: true })
|
||||
.then((files) => {
|
||||
if (branchFilesFetchIdRef.current === fetchId) setBranchFiles(files);
|
||||
if (branchFilesFetchIdRef.current === fetchId) setBranchFilesResult({ key: branchQueryKey, files });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (branchFilesFetchIdRef.current === fetchId) {
|
||||
setBranchFilesError(error instanceof Error ? error.message : t('diffView.branch.loadError'));
|
||||
setBranchFilesResult({ key: branchQueryKey, error: error instanceof Error ? error.message : t('diffView.branch.loadError') });
|
||||
}
|
||||
});
|
||||
}, [branchBase, currentBranch, effectiveDirectory, t]);
|
||||
}, [branchBase, branchQueryKey, currentBranch, effectiveDirectory, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeDiffScope === 'branch') {
|
||||
reloadBranchFiles();
|
||||
}
|
||||
}, [activeDiffScope, reloadBranchFiles]);
|
||||
return () => { branchFilesFetchIdRef.current += 1; };
|
||||
}, [activeDiffScope, branchRevision, reloadBranchFiles]);
|
||||
|
||||
// Range diffs are fetched per expanded file: unlike working/staged diffs
|
||||
// there is no per-file cache channel, so patch data lives in a range-keyed
|
||||
// local cache. Stale completions from a previous range cannot write into
|
||||
// the new range's cache (see useRangeKeyedCache).
|
||||
const branchDiffRangeKey = activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase
|
||||
? branchRangeKey(effectiveDirectory, branchBase, currentBranch)
|
||||
const comparisonRangeKey = activeDiffScope === 'commit' ? (commitFiles ? commitQueryKey : null) : activeDiffScope === 'branch' && effectiveDirectory && currentBranch && branchBase
|
||||
? branchRangeKey(effectiveDirectory, branchBase, currentBranch) + getRuntimeKey()
|
||||
: null;
|
||||
const branchDiffPathsKey = React.useMemo(
|
||||
() => (activeDiffScope === 'branch' ? Array.from(expandedFiles).sort().join('\0') : ''),
|
||||
const comparisonPathsKey = React.useMemo(
|
||||
() => (activeDiffScope === 'branch' || activeDiffScope === 'commit' ? Array.from(expandedFiles).sort().join('\0') : ''),
|
||||
[activeDiffScope, expandedFiles]
|
||||
);
|
||||
|
||||
const fetchBranchDiffEntry = React.useCallback(
|
||||
(filePath: string) => {
|
||||
if (!effectiveDirectory || !branchBase || !currentBranch) {
|
||||
return Promise.reject(new Error('branch range is unavailable'));
|
||||
const fetchComparisonDiffEntry = React.useCallback(
|
||||
async (filePath: string): Promise<ComparisonDiffResult> => {
|
||||
if (!effectiveDirectory) return EMPTY_COMPARISON_DIFF;
|
||||
try {
|
||||
if (activeDiffScope === 'commit' && selectedCommitHash) {
|
||||
const response = await getGitCommitDiff(effectiveDirectory, {
|
||||
hash: selectedCommitHash, path: filePath,
|
||||
previousPath: commitFilesByPath.get(filePath)?.previousPath,
|
||||
contextLines: loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES,
|
||||
});
|
||||
return { status: 'ready', data: createTextDiffDataFromPatch(filePath, response.diff, loadFullFiles ? 'full' : 'patch') };
|
||||
}
|
||||
if (!branchBase || !currentBranch) return EMPTY_COMPARISON_DIFF;
|
||||
const response = await getGitRangeDiff(effectiveDirectory, {
|
||||
base: branchBase,
|
||||
head: currentBranch,
|
||||
path: filePath,
|
||||
includeWorkingTree: true,
|
||||
contextLines: loadFullFiles ? FULL_CONTEXT_DIFF_LINES : DEFAULT_CONTEXT_DIFF_LINES,
|
||||
});
|
||||
return { status: 'ready', data: createTextDiffDataFromPatch(filePath, response.diff, loadFullFiles ? 'full' : 'patch') };
|
||||
} catch (error) {
|
||||
return { status: 'error', message: error instanceof Error ? error.message : t('diffView.state.failedToLoadDiff') };
|
||||
}
|
||||
return getGitRangeDiff(effectiveDirectory, { base: branchBase, head: currentBranch, path: filePath })
|
||||
.then((response) => createTextDiffDataFromPatch(filePath, response.diff, 'patch'));
|
||||
},
|
||||
[branchBase, currentBranch, effectiveDirectory]
|
||||
[activeDiffScope, branchBase, commitFilesByPath, currentBranch, effectiveDirectory, loadFullFiles, selectedCommitHash, t]
|
||||
);
|
||||
|
||||
const branchDiffData = useRangeKeyedCache<DiffData>(
|
||||
branchDiffRangeKey,
|
||||
branchDiffPathsKey,
|
||||
branchDiffRangeKey ? fetchBranchDiffEntry : null,
|
||||
EMPTY_BRANCH_DIFF_PLACEHOLDER
|
||||
const comparisonDiffData = useRangeKeyedCache<ComparisonDiffResult>(
|
||||
comparisonRangeKey,
|
||||
comparisonPathsKey,
|
||||
comparisonRangeKey ? fetchComparisonDiffEntry : null,
|
||||
EMPTY_COMPARISON_DIFF,
|
||||
JSON.stringify([activeDiffScope === 'branch' ? branchRevision : '', comparisonRetryRevision, loadFullFiles])
|
||||
);
|
||||
|
||||
const branchFileCount = branchFiles?.length ?? null;
|
||||
|
||||
const changedFiles: FileEntry[] = React.useMemo(() => {
|
||||
if (activeDiffScope === 'commit') {
|
||||
return (commitFiles ?? []).map((file) => ({
|
||||
path: file.path, index: '', working_dir: file.changeType,
|
||||
insertions: file.insertions, deletions: file.deletions, isNew: file.changeType === 'A',
|
||||
}));
|
||||
}
|
||||
if (activeDiffScope === 'branch') {
|
||||
return (branchFiles ?? [])
|
||||
.map((file) => ({
|
||||
@@ -1363,7 +1409,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
isNew: isNewStatusFile(file),
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [activeDiffScope, branchFiles, lastTurnDiffs, status]);
|
||||
}, [activeDiffScope, branchFiles, commitFiles, lastTurnDiffs, status]);
|
||||
|
||||
const changedFilePathsKey = React.useMemo(
|
||||
() => changedFiles.map((file) => file.path).join('\0'),
|
||||
@@ -1937,13 +1983,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}}
|
||||
staged={getFileStaged(file.path)}
|
||||
loadFullFiles={loadFullFiles}
|
||||
readOnlyActions={activeDiffScope === 'branch'}
|
||||
readOnlyActions={activeDiffScope === 'branch' || activeDiffScope === 'commit'}
|
||||
comparisonDiff={activeDiffScope === 'branch' || activeDiffScope === 'commit'
|
||||
? comparisonDiffData.get(file.path) ?? EMPTY_COMPARISON_DIFF
|
||||
: undefined}
|
||||
onRetryComparisonDiff={() => setComparisonRetryRevision((revision) => revision + 1)}
|
||||
initialDiffData={
|
||||
activeDiffScope === 'turn'
|
||||
? lastTurnDiffData.get(file.path) ?? null
|
||||
: activeDiffScope === 'branch'
|
||||
? branchDiffData.get(file.path) ?? null
|
||||
: null
|
||||
: null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
@@ -1981,6 +2029,25 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (activeDiffScope === 'commit') {
|
||||
if (commitFilesError) {
|
||||
return <div className="flex flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<p className="typography-meta text-muted-foreground">{commitFilesError}</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setCommitFilesRetry((value) => value + 1)}>{t('diffView.actions.retry')}</Button>
|
||||
</div>;
|
||||
}
|
||||
if (!selectedCommitHash && !commitComparison.loading) {
|
||||
return <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{commitComparison.error ?? t('commitComparison.noCommits')}
|
||||
</div>;
|
||||
}
|
||||
if (!commitFiles) {
|
||||
return <div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />{t('diffView.state.loadingDiff')}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeDiffScope === 'branch') {
|
||||
if (!isBranchBaseResolved) {
|
||||
return (
|
||||
@@ -1992,45 +2059,11 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}
|
||||
|
||||
if (!branchBase) {
|
||||
const eligibleBranches = (branches?.all ?? [])
|
||||
.map((name: string) => name.replace(/^remotes\//, ''))
|
||||
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
|
||||
.sort();
|
||||
const candidateBranches = rankByQuery(eligibleBranches, basePickerSearch, (name) => [name]);
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<Icon name="git-branch" className="size-6 text-muted-foreground" />
|
||||
<div className="typography-ui-label font-semibold text-foreground">{t('diffView.branch.noBaseTitle')}</div>
|
||||
<div className="max-w-sm typography-micro text-muted-foreground">{t('diffView.branch.noBaseDescription')}</div>
|
||||
<input
|
||||
type="text"
|
||||
value={basePickerSearch}
|
||||
onChange={(event) => setBasePickerSearch(event.target.value)}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
aria-label={t('gitView.branch.searchPlaceholder')}
|
||||
className="w-full max-w-sm rounded-md border border-border/60 bg-[var(--surface-elevated)] px-2.5 py-1.5 typography-meta text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
/>
|
||||
<ScrollableOverlay outerClassName="max-h-48 w-full max-w-sm min-h-0" className="px-1 py-1">
|
||||
{candidateBranches.length === 0 ? (
|
||||
<div className="px-2 py-3 typography-meta text-muted-foreground">
|
||||
{t('gitView.branch.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{candidateBranches.map((branch: string) => (
|
||||
<button
|
||||
key={branch}
|
||||
type="button"
|
||||
onClick={() => effectiveDirectory && currentBranch && setBaseOverride(effectiveDirectory, currentBranch, branch)}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
>
|
||||
<Icon name="git-branch" className="size-3.5 text-primary" />
|
||||
<span className="truncate typography-ui-label text-foreground" title={branch}>{branch}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
<div className="max-w-sm typography-micro text-muted-foreground">{t('gitView.pr.toast.baseBranchRequired')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2065,7 +2098,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges')
|
||||
: activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchBase })
|
||||
: activeDiffScope === 'commit' ? t('commitComparison.emptyDiff')
|
||||
: activeDiffScope === 'branch' && branchBase ? t('diffView.branch.empty', { base: branchRefLabel(branchBase) })
|
||||
: t('diffView.state.cleanWorkingTree')}
|
||||
</div>
|
||||
);
|
||||
@@ -2088,13 +2122,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
{!isMobile && (
|
||||
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' || activeDiffScope === 'branch' ? (
|
||||
activeDiffScope !== 'all' ? (
|
||||
<ChangeScopeSelector
|
||||
scope={activeDiffScope}
|
||||
workingCount={workingFileCount}
|
||||
stagedCount={stagedFileCount}
|
||||
turnCount={turnFileCount}
|
||||
branchCount={branchFileCount}
|
||||
commitCount={commitFiles?.length ?? null}
|
||||
showCommitOption={!isVSCodeRuntime()}
|
||||
showBranchOption={showBranchOption}
|
||||
onScopeChange={(scope) => {
|
||||
setActiveDiffScope(scope);
|
||||
@@ -2113,6 +2149,28 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{activeDiffScope === 'branch' && (
|
||||
<BranchComparisonSelector
|
||||
key={JSON.stringify([effectiveDirectory, currentBranch])}
|
||||
branches={branches?.all ?? []}
|
||||
currentBranch={currentBranch}
|
||||
base={branchBase}
|
||||
onSelect={(base) => {
|
||||
if (effectiveDirectory && currentBranch) setBaseOverride(effectiveDirectory, currentBranch, base);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeDiffScope === 'commit' && (
|
||||
<CommitComparisonSelector
|
||||
key={JSON.stringify([effectiveDirectory, currentBranch])}
|
||||
commits={commitComparison.commits}
|
||||
selectedHash={selectedCommitHash}
|
||||
loading={commitComparison.loading}
|
||||
error={commitComparison.error}
|
||||
onSelect={commitComparison.select}
|
||||
onRefresh={() => void commitComparison.refresh()}
|
||||
/>
|
||||
)}
|
||||
{changedFiles.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -2161,7 +2219,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
// while looking at staged changes should review
|
||||
// staged changes, not whatever the panel showed last.
|
||||
const directory = effectiveDirectory ?? '';
|
||||
requestWalkthroughSource(directory, {
|
||||
requestWalkthroughSource(directory, activeDiffScope === 'commit' && selectedCommitHash ? {
|
||||
kind: 'commit', hash: selectedCommitHash,
|
||||
} : activeDiffScope === 'branch' && branchBase && currentBranch ? {
|
||||
kind: 'branch',
|
||||
baseRef: branchBase,
|
||||
headRef: currentBranch,
|
||||
} : {
|
||||
kind: 'working-tree',
|
||||
scope: activeDiffScope === 'staged' || activeDiffScope === 'working'
|
||||
? activeDiffScope
|
||||
|
||||
@@ -219,6 +219,48 @@ const installMinimalDom = () => {
|
||||
};
|
||||
|
||||
describe('useRangeKeyedCache', () => {
|
||||
test('refreshes visible paths on revision changes without blanking completed diffs or refetching on rerender', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root = createRoot(dom.container);
|
||||
let revision = '1';
|
||||
let paths = 'a.ts';
|
||||
const requests: Array<{ path: string; result: ReturnType<typeof deferred<string>> }> = [];
|
||||
type CapturedEntries = { entries: ReadonlyMap<string, string> | null };
|
||||
const captured: CapturedEntries = { entries: null };
|
||||
const Harness = () => {
|
||||
captured.entries = useRangeKeyedCache('range', paths, (path) => {
|
||||
const result = deferred<string>();
|
||||
requests.push({ path, result });
|
||||
return result.promise;
|
||||
}, 'loading', revision);
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await act(async () => requests[0].result.resolve('old'));
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(requests).toHaveLength(1);
|
||||
revision = '2';
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(captured.entries?.get('a.ts')).toBe('old');
|
||||
// Changing visible paths cancels the stale refresh and must retry it.
|
||||
paths = 'a.ts\0b.ts';
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
expect(requests.map(({ path }) => path)).toEqual(['a.ts', 'a.ts', 'a.ts', 'b.ts']);
|
||||
await act(async () => {
|
||||
requests[2].result.resolve('current');
|
||||
requests[3].result.resolve('new file');
|
||||
requests[1].result.resolve('stale');
|
||||
});
|
||||
expect(captured.entries?.get('a.ts')).toBe('current');
|
||||
expect(captured.entries?.get('b.ts')).toBe('new file');
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a stale completion from the previous range cannot write into the new range', async () => {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
|
||||
@@ -141,16 +141,23 @@ export const useBoundedDirectoryRetry = (
|
||||
* old `fetchEntry` promise resolves (or rejects) after the range switched.
|
||||
* - Reservations that never completed are released on cleanup so a later run
|
||||
* retries those paths instead of showing the placeholder forever.
|
||||
* - A revision change refreshes requested paths while retaining completed
|
||||
* same-range values until replacement. Unchanged revisions do no extra work;
|
||||
* closed paths refresh when requested again. Range switches hide old values
|
||||
* immediately, before effects run.
|
||||
*/
|
||||
export const useRangeKeyedCache = <T>(
|
||||
rangeKey: string | null,
|
||||
pathsKey: string,
|
||||
fetchEntry: ((path: string) => Promise<T>) | null,
|
||||
placeholder: T
|
||||
placeholder: T,
|
||||
revision = ''
|
||||
): ReadonlyMap<string, T> => {
|
||||
const [entries, setEntries] = React.useState<Map<string, T>>(() => new Map());
|
||||
const entriesRef = React.useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
const completedRevisions = React.useRef(new Map<string, string>());
|
||||
const entriesRangeKey = React.useRef<string | null>(null);
|
||||
|
||||
// The fetcher is read through a ref so a caller passing an inline arrow (a
|
||||
// new function every render) cannot restart the fetch effect in a loop.
|
||||
@@ -169,7 +176,8 @@ export const useRangeKeyedCache = <T>(
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!rangeKey) return;
|
||||
entriesRangeKey.current = rangeKey;
|
||||
completedRevisions.current.clear();
|
||||
entriesRef.current = new Map();
|
||||
setEntries(entriesRef.current);
|
||||
}, [rangeKey]);
|
||||
@@ -181,31 +189,34 @@ export const useRangeKeyedCache = <T>(
|
||||
}
|
||||
let cancelled = false;
|
||||
const pendingReservations = new Set<string>();
|
||||
const revisions = completedRevisions.current;
|
||||
|
||||
for (const path of pathsKey.split('\0')) {
|
||||
if (entriesRef.current.has(path)) continue;
|
||||
if (entriesRef.current.has(path) && revisions.get(path) === revision) continue;
|
||||
pendingReservations.add(path);
|
||||
writeEntry(path, placeholder);
|
||||
if (!entriesRef.current.has(path)) writeEntry(path, placeholder);
|
||||
fetcher(path)
|
||||
.then((value) => {
|
||||
if (cancelled) return;
|
||||
pendingReservations.delete(path);
|
||||
revisions.set(path, revision);
|
||||
writeEntry(path, value);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// Release the reservation so a later run can retry this path.
|
||||
pendingReservations.delete(path);
|
||||
revisions.delete(path);
|
||||
writeEntry(path, null);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const path of pendingReservations) {
|
||||
writeEntry(path, null);
|
||||
if (!revisions.has(path)) writeEntry(path, null);
|
||||
}
|
||||
};
|
||||
}, [pathsKey, placeholder, rangeKey, writeEntry]);
|
||||
}, [pathsKey, placeholder, rangeKey, revision, writeEntry]);
|
||||
|
||||
return entries;
|
||||
return entriesRangeKey.current === rangeKey ? entries : new Map();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { act, useState } from 'react';
|
||||
import { test, expect } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
test('allows repeated base selection with search and keyboard navigation', async () => {
|
||||
const dom = new Window({ url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
window: dom,
|
||||
document: dom.document,
|
||||
navigator: dom.navigator,
|
||||
location: dom.location,
|
||||
Element: dom.Element,
|
||||
HTMLElement: dom.HTMLElement,
|
||||
HTMLInputElement: dom.HTMLInputElement,
|
||||
Node: dom.Node,
|
||||
Event: dom.Event,
|
||||
KeyboardEvent: dom.KeyboardEvent,
|
||||
MouseEvent: dom.MouseEvent,
|
||||
MutationObserver: dom.MutationObserver,
|
||||
ResizeObserver: dom.ResizeObserver,
|
||||
getComputedStyle: dom.getComputedStyle.bind(dom),
|
||||
requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
|
||||
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom),
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
}
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { BranchComparisonSelector } = await import('./BranchComparisonSelector');
|
||||
const { I18nProvider } = await import('@/lib/i18n');
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const choices: string[] = [];
|
||||
function Harness() {
|
||||
const [base, setBase] = useState<string | null>(null);
|
||||
return <BranchComparisonSelector
|
||||
branches={['feature', 'main', 'parent', 'remotes/origin/main']}
|
||||
currentBranch="feature"
|
||||
base={base}
|
||||
onSelect={(ref) => { choices.push(ref); setBase(ref); }}
|
||||
/>;
|
||||
}
|
||||
const trigger = () => {
|
||||
const button = container.querySelector('button');
|
||||
if (!button) throw new Error('Missing branch trigger');
|
||||
return button;
|
||||
};
|
||||
const selectedRef = () => document.querySelector('[cmdk-item][data-selected="true"]')?.getAttribute('data-value');
|
||||
const press = async (key: string, ctrlKey = false) => {
|
||||
const input = document.querySelector('input');
|
||||
if (!input) throw new Error('Missing branch search');
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key, ctrlKey, bubbles: true, cancelable: true }));
|
||||
});
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
|
||||
await act(async () => trigger().click());
|
||||
expect(document.querySelector('[data-value="refs/heads/feature"]')).toBeNull();
|
||||
await press('ArrowDown');
|
||||
const afterDown = selectedRef();
|
||||
expect(afterDown).toBe('refs/heads/parent');
|
||||
await press('ArrowUp');
|
||||
await press('n', true);
|
||||
expect(selectedRef()).toBe(afterDown);
|
||||
await press('p', true);
|
||||
await press('Enter');
|
||||
expect(choices).toHaveLength(1);
|
||||
expect(trigger().textContent).toContain('main');
|
||||
|
||||
await act(async () => trigger().click());
|
||||
const input = document.querySelector('input');
|
||||
if (!input) throw new Error('Missing branch search');
|
||||
await act(async () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, 'value')?.set;
|
||||
setter?.call(input, 'parent');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1);
|
||||
await press('Enter');
|
||||
expect(choices).toEqual(['refs/heads/main', 'refs/heads/parent']);
|
||||
expect(trigger().textContent).toContain('parent');
|
||||
await act(async () => trigger().click());
|
||||
expect(document.querySelector('input')?.value).toBe('');
|
||||
const remote = document.querySelector<HTMLElement>('[data-value="refs/remotes/origin/main"]');
|
||||
if (!remote) throw new Error('Missing remote branch');
|
||||
await act(async () => remote.click());
|
||||
expect(choices.at(-1)).toBe('refs/remotes/origin/main');
|
||||
expect(trigger().textContent).toContain('origin/main');
|
||||
await act(async () => {
|
||||
trigger().dispatchEvent(new KeyboardEvent('keydown', { key: 'n', ctrlKey: true, bubbles: true }));
|
||||
});
|
||||
expect(document.querySelector('input')).toBeNull();
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await dom.happyDOM.abort();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { branchRefLabel } from './baseBranch';
|
||||
|
||||
interface BranchComparisonSelectorProps {
|
||||
branches: readonly string[];
|
||||
currentBranch: string | null;
|
||||
base: string | null;
|
||||
onSelect: (ref: string) => void;
|
||||
}
|
||||
|
||||
export function BranchComparisonSelector({ branches, currentBranch, base, onSelect }: BranchComparisonSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const label = base ? branchRefLabel(base) : t('gitView.pr.field.baseBranch');
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setSearch('');
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(dropdownTriggerVariants({ size: 'sm' }), 'min-w-0 max-w-48')}
|
||||
aria-label={t('gitView.pr.field.baseBranch')}
|
||||
title={label}
|
||||
disabled={!currentBranch}
|
||||
>
|
||||
<Icon name="git-branch" className="size-3.5" />
|
||||
<span className="truncate">{label}</span>
|
||||
<Icon name="arrow-down-s" className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72 max-w-[calc(100vw-2rem)] p-0">
|
||||
<Command shouldFilter={false} onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape') event.stopPropagation();
|
||||
}}>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||
aria-label={t('gitView.branch.searchPlaceholder')}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{open && rankByQuery(
|
||||
[...new Set(branches)]
|
||||
.filter((name) => name !== currentBranch)
|
||||
.sort()
|
||||
.map((name) => ({
|
||||
ref: name.startsWith('remotes/') ? `refs/${name}` : `refs/heads/${name}`,
|
||||
label: branchRefLabel(name),
|
||||
})),
|
||||
search,
|
||||
(branch) => [branch.label],
|
||||
).map((branch) => (
|
||||
<CommandItem key={branch.ref} value={branch.ref} onSelect={() => {
|
||||
onSelect(branch.ref);
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
}}>
|
||||
<span className="min-w-0 flex-1 truncate" title={branch.ref}>{branch.label}</span>
|
||||
{(branch.ref === base || branch.label === base) && <Icon name="check" className="size-3.5" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { act, useState } from 'react';
|
||||
import { expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import type { GitLogEntry } from '@/lib/api/types';
|
||||
|
||||
test('shows commit metadata and shares repeated searched selections between two pickers', async () => {
|
||||
const dom = new Window({ url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
window: dom, document: dom.document, navigator: dom.navigator, location: dom.location,
|
||||
Element: dom.Element, HTMLElement: dom.HTMLElement, HTMLInputElement: dom.HTMLInputElement,
|
||||
Node: dom.Node, Event: dom.Event, KeyboardEvent: dom.KeyboardEvent, MouseEvent: dom.MouseEvent,
|
||||
MutationObserver: dom.MutationObserver, ResizeObserver: dom.ResizeObserver,
|
||||
getComputedStyle: dom.getComputedStyle.bind(dom), requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
|
||||
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom), IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
}
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('@/lib/i18n');
|
||||
const { CommitComparisonSelector } = await import('./CommitComparisonSelector');
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const commits: GitLogEntry[] = ['a', 'b'].map((letter, index) => ({
|
||||
hash: letter.repeat(40), message: index === 0 ? 'fix: first commit' : 'feat: second commit',
|
||||
author_name: 'Test Author', author_email: 'test@example.com', date: '2026-09-09T09:22:00Z',
|
||||
body: '', refs: '', parents: [], filesChanged: 1, insertions: 2, deletions: 1,
|
||||
}));
|
||||
const selected: string[] = [];
|
||||
let refreshes = 0;
|
||||
function Harness() {
|
||||
const [hash, setHash] = useState<string | null>(null);
|
||||
return <>{['changes', 'walkthrough'].map((name) => <section key={name} data-picker={name}>
|
||||
<CommitComparisonSelector commits={commits} selectedHash={hash} loading={false} error={null}
|
||||
onRefresh={() => { refreshes += 1; }}
|
||||
onSelect={(commit) => { selected.push(commit.hash); setHash(commit.hash); }} />
|
||||
</section>)}</>;
|
||||
}
|
||||
const trigger = (name: string) => {
|
||||
const button = container.querySelector<HTMLButtonElement>(`[data-picker="${name}"] button`);
|
||||
if (!button) throw new Error('Missing commit picker');
|
||||
return button;
|
||||
};
|
||||
const input = () => {
|
||||
const value = document.querySelector('input');
|
||||
if (!value) throw new Error('Missing commit search');
|
||||
return value;
|
||||
};
|
||||
const press = async (key: string, ctrlKey = false) => {
|
||||
await act(async () => { input().dispatchEvent(new KeyboardEvent('keydown', { key, ctrlKey, bubbles: true, cancelable: true })); });
|
||||
};
|
||||
try {
|
||||
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
|
||||
await act(async () => trigger('changes').click());
|
||||
const first = document.querySelector('[cmdk-item]');
|
||||
expect(first?.textContent).toContain('fix: first commit');
|
||||
expect(first?.textContent).toContain('Test Author');
|
||||
expect(first?.textContent).toContain('2026');
|
||||
expect(first?.textContent).toContain('aaaaaaaa');
|
||||
await press('ArrowDown');
|
||||
expect(document.querySelector('[cmdk-item][data-selected="true"]')?.getAttribute('data-value')).toBe('b'.repeat(40));
|
||||
await press('p', true);
|
||||
await press('n', true);
|
||||
await press('Enter');
|
||||
expect(trigger('walkthrough').textContent).toContain('bbbbbbbb');
|
||||
|
||||
await act(async () => trigger('walkthrough').click());
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, 'value')?.set?.call(input(), 'first');
|
||||
input().dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
expect(document.querySelectorAll('[cmdk-item]')).toHaveLength(1);
|
||||
await press('Enter');
|
||||
expect(trigger('changes').textContent).toContain('aaaaaaaa');
|
||||
expect(selected).toEqual(['b'.repeat(40), 'a'.repeat(40)]);
|
||||
expect(refreshes).toBe(2);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await dom.happyDOM.abort();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import type { GitLogEntry } from '@/lib/api/types';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommitComparisonSelectorProps {
|
||||
commits: readonly GitLogEntry[];
|
||||
selectedHash: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onSelect: (commit: GitLogEntry) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function CommitComparisonSelector({ commits, selectedHash, loading, error, onSelect, onRefresh }: CommitComparisonSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
const timeFormat = useUIStore((state) => state.timeFormatPreference);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={(value) => {
|
||||
setOpen(value);
|
||||
if (!value) setSearch('');
|
||||
else if (!loading) onRefresh();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className={cn(dropdownTriggerVariants({ size: 'sm' }), 'min-w-0 max-w-48')}
|
||||
aria-label={t('commitComparison.select')} title={selectedHash ?? t('commitComparison.select')}>
|
||||
<Icon name="git-commit" className="size-3.5" />
|
||||
<span className="truncate">{selectedHash?.slice(0, 8) ?? t('commitComparison.select')}</span>
|
||||
<Icon name="arrow-down-s" className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[32rem] max-w-[calc(100vw-2rem)] p-0">
|
||||
<Command shouldFilter={false} onKeyDown={(event) => { if (event.key !== 'Escape') event.stopPropagation(); }}>
|
||||
<CommandInput autoFocus value={search} onValueChange={setSearch} placeholder={t('commitComparison.search')} aria-label={t('commitComparison.search')} />
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 p-4 typography-meta text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />{t('diffView.state.loadingChanges')}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-2 p-4 typography-meta text-muted-foreground">
|
||||
<span>{t('commitComparison.loadError')}</span>
|
||||
<span className="max-w-full break-words">{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>{t('diffView.actions.retry')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('commitComparison.noCommits')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{open && rankByQuery(commits, search, (commit) => [commit.message, commit.author_name, commit.hash]).map((commit) => (
|
||||
<CommandItem key={commit.hash} value={commit.hash} onSelect={() => { onSelect(commit); setOpen(false); setSearch(''); }}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate typography-ui-label font-semibold" title={commit.message}>{commit.message}</div>
|
||||
<div className="truncate typography-meta text-muted-foreground">
|
||||
{commit.author_name} · {Number.isNaN(new Date(commit.date).getTime()) ? commit.date : formatDateTimeForPreference(new Date(commit.date), timeFormat, {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})} · {commit.hash.slice(0, 8)}
|
||||
</div>
|
||||
</div>
|
||||
{commit.hash === selectedHash && <Icon name="check" className="size-3.5" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)}
|
||||
</Command>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export const branchRefLabel = (ref: string): string => ref.replace(/^refs\/(heads|remotes)\//, '').replace(/^remotes\//, '');
|
||||
|
||||
/**
|
||||
* Derives the base ("target") branch a feature branch should compare and
|
||||
* merge against. Shared by GitView and the standalone pull-request surface so
|
||||
|
||||
@@ -16,7 +16,11 @@ import { openExternalUrl } from '@/lib/url';
|
||||
import { buildWalkthroughView } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
|
||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||
import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch';
|
||||
import { useBranchComparisonBase } from '@/hooks/useBranchComparisonBase';
|
||||
import { useCommitComparison } from '@/hooks/useCommitComparison';
|
||||
import { CommitComparisonSelector } from '@/components/views/git/CommitComparisonSelector';
|
||||
import { BranchComparisonSelector } from '@/components/views/git/BranchComparisonSelector';
|
||||
import { useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useGitBranches, useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
||||
@@ -159,6 +163,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
[setStoredTocWidth, tocWidth]
|
||||
);
|
||||
const [scope, setScope] = useState<WalkthroughWorkingTreeScope>('all');
|
||||
const [pendingSourceSelection, setPendingSourceSelection] = useState<{ directory: string; kind: 'branch' | 'commit' } | null>(null);
|
||||
const [activeStopId, setActiveStopId] = useState<string | null>(null);
|
||||
const [scrollToStopId, setScrollToStopId] = useState<string | null>(null);
|
||||
const [visitedStopIds, setVisitedStopIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
@@ -175,6 +180,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
|
||||
const status = useGitStatus(directory || null);
|
||||
const branches = useGitBranches(directory || null);
|
||||
const setBaseOverride = useGitBaseBranchStore((state) => state.setOverride);
|
||||
const ensureAll = useGitStore((state) => state.ensureAll);
|
||||
const { github, git } = useRuntimeAPIs();
|
||||
|
||||
@@ -182,37 +188,18 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
if (directory) void ensureAll(directory, git);
|
||||
}, [directory, ensureAll, git]);
|
||||
|
||||
// The branch source reviews everything on this branch that is not on its
|
||||
// base. Three-dot semantics server-side mean merges from the base are
|
||||
// already excluded.
|
||||
// Changes and walkthrough share the explicit base choice and reflog detection.
|
||||
const currentBranch = status?.current ?? null;
|
||||
const choosingCommit = pendingSourceSelection?.directory === directory && pendingSourceSelection.kind === 'commit' && !requestedSource;
|
||||
const isCommitScope = choosingCommit || requestedSource?.kind === 'commit';
|
||||
const commitComparison = useCommitComparison(directory || null, currentBranch, visible && isCommitScope,
|
||||
requestedSource?.kind === 'commit' ? requestedSource.hash : undefined);
|
||||
const selectedCommitHash = commitComparison.selectedCommit?.hash ?? (requestedSource?.kind === 'commit' ? requestedSource.hash : null);
|
||||
const { base: comparisonBase, resolved: comparisonBaseResolved, revision: branchRevision } = useBranchComparisonBase(directory || null, currentBranch, visible);
|
||||
const branchSource = useMemo<WalkthroughSource | null>(() => {
|
||||
const headRef = currentBranch;
|
||||
if (!headRef) return null;
|
||||
const all = branches?.all ?? [];
|
||||
const localBranches = all.filter((name) => !name.startsWith('remotes/'));
|
||||
const remoteBranches = all
|
||||
.filter((name) => name.startsWith('remotes/'))
|
||||
.map((name) => name.slice('remotes/'.length));
|
||||
const remoteNames = new Set(
|
||||
remoteBranches
|
||||
.map((name) => name.split('/')[0])
|
||||
.filter(Boolean)
|
||||
);
|
||||
const trackingRemote = status?.tracking?.split('/')[0];
|
||||
const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote])
|
||||
?? branches?.defaultBranches?.origin;
|
||||
const baseRef = deriveBaseBranch({
|
||||
remoteNames,
|
||||
localBranches,
|
||||
defaultBranch,
|
||||
headBranch: headRef,
|
||||
});
|
||||
if (!baseRef || baseRef === headRef || !hasResolvableBaseBranch({ baseBranch: baseRef, localBranches, remoteBranches })) {
|
||||
return null;
|
||||
}
|
||||
return { kind: 'branch', baseRef, headRef };
|
||||
}, [branches, currentBranch, status?.tracking]);
|
||||
if (!currentBranch || !comparisonBase || comparisonBase === currentBranch) return null;
|
||||
return { kind: 'branch', baseRef: comparisonBase, headRef: currentBranch };
|
||||
}, [comparisonBase, currentBranch]);
|
||||
|
||||
// The pull request for this branch used to appear only after visiting the PR
|
||||
// panel, because nothing else asked GitHub about it. Ask here too: the status
|
||||
@@ -258,9 +245,18 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
));
|
||||
|
||||
const source = useMemo<WalkthroughSource>(
|
||||
() => requestedSource ?? { kind: 'working-tree', scope },
|
||||
[requestedSource, scope]
|
||||
() => isCommitScope && selectedCommitHash
|
||||
? { kind: 'commit', hash: selectedCommitHash }
|
||||
: requestedSource?.kind === 'branch'
|
||||
? branchSource ?? requestedSource
|
||||
: requestedSource ?? { kind: 'working-tree', scope },
|
||||
[branchSource, isCommitScope, requestedSource, scope, selectedCommitHash]
|
||||
);
|
||||
const choosingBranchBase = pendingSourceSelection?.directory === directory && pendingSourceSelection.kind === 'branch' && !requestedSource;
|
||||
const isBranchScope = choosingBranchBase || source.kind === 'branch';
|
||||
const branchNeedsBase = choosingBranchBase || (source.kind === 'branch' && !branchSource);
|
||||
const commitNeedsSelection = choosingCommit || (isCommitScope && !selectedCommitHash);
|
||||
const needsSourceSelection = branchNeedsBase || commitNeedsSelection;
|
||||
|
||||
// Offer whichever pull request we know about: the one already selected, or
|
||||
// the one this branch has.
|
||||
@@ -271,6 +267,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
|
||||
const selectWorkingTree = useCallback(
|
||||
(value: WalkthroughWorkingTreeScope) => {
|
||||
setPendingSourceSelection(null);
|
||||
clearRequestedSource(directory);
|
||||
setScope(value);
|
||||
},
|
||||
@@ -281,6 +278,16 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
const generate = useWalkthroughStore((state) => state.generate);
|
||||
const cancel = useWalkthroughStore((state) => state.cancel);
|
||||
const requestSource = useWalkthroughStore((state) => state.requestSource);
|
||||
useEffect(() => {
|
||||
if (!choosingBranchBase || !branchSource) return;
|
||||
requestSource(directory, branchSource);
|
||||
setPendingSourceSelection(null);
|
||||
}, [branchSource, choosingBranchBase, directory, requestSource]);
|
||||
useEffect(() => {
|
||||
if (!choosingCommit || !selectedCommitHash) return;
|
||||
requestSource(directory, { kind: 'commit', hash: selectedCommitHash });
|
||||
setPendingSourceSelection(null);
|
||||
}, [choosingCommit, directory, requestSource, selectedCommitHash]);
|
||||
const selectModel = useWalkthroughStore((state) => state.selectModel);
|
||||
const selectedModel = useWalkthroughStore((state) => state.getSelectedModel(directory, source));
|
||||
const selectLanguage = useWalkthroughStore((state) => state.selectLanguage);
|
||||
@@ -302,11 +309,12 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
// Reloads on a model or language change: whether this diff fits, and whether
|
||||
// the model can produce structured output, are answers about a specific
|
||||
// request — and the language instruction is part of that request.
|
||||
const sourceRevision = source.kind === 'branch' ? branchRevision : '';
|
||||
useEffect(() => {
|
||||
void load(directory, source, { language: activeLanguage });
|
||||
}, [activeLanguage, directory, load, source, selectedModel]);
|
||||
if (visible && !needsSourceSelection) void load(directory, source, { language: activeLanguage });
|
||||
}, [activeLanguage, needsSourceSelection, directory, load, source, selectedModel, sourceRevision, visible]);
|
||||
|
||||
const view = useMemo(() => buildWalkthroughView(entry.result), [entry.result]);
|
||||
const view = useMemo(() => needsSourceSelection ? null : buildWalkthroughView(entry.result), [needsSourceSelection, entry.result]);
|
||||
|
||||
// A new walkthrough is a new reading path: keeping the old progress would
|
||||
// mark stops as visited that the user has never seen.
|
||||
@@ -349,8 +357,8 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
|
||||
const [sourceMenuOpen, setSourceMenuOpen] = useState(false);
|
||||
const [languageMenuOpen, setLanguageMenuOpen] = useState(false);
|
||||
const sourceValue = source.kind === 'working-tree' ? source.scope : source.kind;
|
||||
const sourceLabel = source.kind === 'branch'
|
||||
const sourceValue = isCommitScope ? 'commit' : isBranchScope ? 'branch' : source.kind === 'working-tree' ? source.scope : source.kind;
|
||||
const sourceLabel = isCommitScope ? t('commitComparison.mode') : isBranchScope
|
||||
? t('walkthrough.scope.branch')
|
||||
: source.kind === 'pr'
|
||||
? t('walkthrough.scope.pullRequest', { number: source.number })
|
||||
@@ -489,7 +497,7 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
|
||||
// Not ready, or no usable selected model, means Generate must not look
|
||||
// actionable — including when the resolved model has no login.
|
||||
const generateDisabled = !activeModel || Boolean(entry.readiness && !entry.readiness.ready);
|
||||
const generateDisabled = needsSourceSelection || !activeModel || Boolean(entry.readiness && !entry.readiness.ready);
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
(force: boolean) => {
|
||||
@@ -547,11 +555,23 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
value={sourceValue}
|
||||
onValueChange={(value) => {
|
||||
setSourceMenuOpen(false);
|
||||
if (value === 'commit') {
|
||||
clearRequestedSource(directory);
|
||||
setPendingSourceSelection({ directory, kind: 'commit' });
|
||||
return;
|
||||
}
|
||||
if (value === 'branch') {
|
||||
if (branchSource) requestSource(directory, branchSource);
|
||||
if (branchSource) {
|
||||
setPendingSourceSelection(null);
|
||||
requestSource(directory, branchSource);
|
||||
} else {
|
||||
clearRequestedSource(directory);
|
||||
setPendingSourceSelection({ directory, kind: 'branch' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value === 'pr') {
|
||||
setPendingSourceSelection(null);
|
||||
if (prSource) requestSource(directory, prSource);
|
||||
return;
|
||||
}
|
||||
@@ -573,19 +593,21 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
: t('walkthrough.scope.working')}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{(branchSource || prSource) && (
|
||||
{currentBranch && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className={SCOPE_GROUP_LABEL_CLASS}>
|
||||
{t('walkthrough.scope.group.committed')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioItem value="branch">
|
||||
{t('walkthrough.scope.branch')}
|
||||
</DropdownMenuRadioItem>
|
||||
</>
|
||||
)}
|
||||
{branchSource && (
|
||||
<DropdownMenuRadioItem value="branch">
|
||||
{t('walkthrough.scope.branch')}
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className={SCOPE_GROUP_LABEL_CLASS}>
|
||||
{t('walkthrough.scope.group.committed')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioItem value="commit">
|
||||
{t('commitComparison.mode')}
|
||||
</DropdownMenuRadioItem>
|
||||
{prSource && (
|
||||
<DropdownMenuRadioItem value="pr">
|
||||
{t('walkthrough.scope.pullRequest', { number: prSource.number })}
|
||||
@@ -595,6 +617,30 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isBranchScope && (
|
||||
<BranchComparisonSelector
|
||||
key={JSON.stringify([directory, currentBranch])}
|
||||
branches={branches?.all ?? []}
|
||||
currentBranch={currentBranch}
|
||||
base={comparisonBase}
|
||||
onSelect={(base) => {
|
||||
if (directory && currentBranch) setBaseOverride(directory, currentBranch, base);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCommitScope && (
|
||||
<CommitComparisonSelector
|
||||
key={JSON.stringify([directory, currentBranch])}
|
||||
commits={commitComparison.commits}
|
||||
selectedHash={selectedCommitHash}
|
||||
loading={commitComparison.loading}
|
||||
error={commitComparison.error}
|
||||
onSelect={commitComparison.select}
|
||||
onRefresh={() => void commitComparison.refresh()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex min-w-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -814,7 +860,22 @@ export const WalkthroughView = ({ directory: rootDirectory, visible = true }: Wa
|
||||
)}
|
||||
|
||||
<div className={cn('flex min-h-0 flex-1', showToc ? 'flex-row' : 'flex-col')}>
|
||||
{blockedReason ? (
|
||||
{commitNeedsSelection ? (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 p-8 typography-meta text-muted-foreground">
|
||||
{commitComparison.loading ? <Icon name="loader-4" className="size-6 animate-spin" />
|
||||
: commitComparison.error ?? t('commitComparison.noCommits')}
|
||||
</div>
|
||||
) : branchNeedsBase ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
{!comparisonBaseResolved && currentBranch ? (
|
||||
<Icon name="loader-4" className="size-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('gitView.pr.toast.baseBranchRequired')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : blockedReason ? (
|
||||
<WalkthroughBlocker
|
||||
reason={blockedReason}
|
||||
model={blockedModel}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getBranchBase } from '@/lib/gitApi';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { gitBaseBranchEntryKey, useGitBaseBranchStore } from '@/stores/useGitBaseBranchStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
|
||||
/** Shared base and freshness identity for Changes and the current-branch walkthrough. */
|
||||
export function useBranchComparisonBase(directory: string | null, branch: string | null, enabled: boolean) {
|
||||
const runtimeKey = useGitStore((state) => state.runtimeKey);
|
||||
const statusFetchedAt = useGitStore((state) => enabled && directory
|
||||
? state.directories.get(directory)?.lastStatusFetch ?? 0
|
||||
: 0);
|
||||
const key = JSON.stringify([runtimeKey, directory, branch]);
|
||||
const overrideKey = directory && branch ? gitBaseBranchEntryKey(directory, branch) : null;
|
||||
const override = useGitBaseBranchStore((state) => overrideKey ? state.overrides[overrideKey] ?? null : null);
|
||||
const [detected, setDetected] = useState<{ key: string; base: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !directory || !branch || override) return;
|
||||
let cancelled = false;
|
||||
const requestRuntime = getRuntimeKey();
|
||||
getBranchBase(directory, branch)
|
||||
.then(({ base }) => {
|
||||
if (cancelled || getRuntimeKey() !== requestRuntime) return;
|
||||
setDetected((previous) => previous?.key === key && previous.base === base ? previous : { key, base });
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || getRuntimeKey() !== requestRuntime) return;
|
||||
// Keep a same-branch answer on transient failure; a new branch needs a choice.
|
||||
setDetected((previous) => previous?.key === key ? previous : { key, base: null });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [branch, directory, enabled, key, override, statusFetchedAt]);
|
||||
|
||||
return {
|
||||
base: override ?? (detected?.key === key ? detected.base : null),
|
||||
resolved: Boolean(override) || detected?.key === key,
|
||||
revision: JSON.stringify([runtimeKey, statusFetchedAt]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { GitLogEntry } from '@/lib/api/types';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { commitSelectionKey, useCommitSelectionStore } from '@/stores/useCommitSelectionStore';
|
||||
import { useRuntimeAPIs } from './useRuntimeAPIs';
|
||||
|
||||
type CommitHistory =
|
||||
| { key: string; status: 'loading' }
|
||||
| { key: string; status: 'ready'; commits: GitLogEntry[] }
|
||||
| { key: string; status: 'error'; message: string };
|
||||
const NO_COMMITS: GitLogEntry[] = [];
|
||||
|
||||
export function useCommitComparison(directory: string | null, branch: string | null, enabled: boolean, preferredHash?: string) {
|
||||
const { git } = useRuntimeAPIs();
|
||||
const { t } = useI18n();
|
||||
const runtimeKey = useGitStore((state) => state.runtimeKey);
|
||||
const key = commitSelectionKey(directory ?? '', branch, runtimeKey);
|
||||
const selectedCommit = useCommitSelectionStore((state) => state.selections.get(key) ?? null);
|
||||
const selectCommit = useCommitSelectionStore((state) => state.select);
|
||||
const [history, setHistory] = useState<CommitHistory | null>(null);
|
||||
const requestId = useRef(0);
|
||||
const preferredHashRef = useRef(preferredHash);
|
||||
preferredHashRef.current = preferredHash;
|
||||
const refresh = useCallback(async () => {
|
||||
if (!directory || !enabled) return;
|
||||
const id = ++requestId.current;
|
||||
const requestRuntime = getRuntimeKey();
|
||||
setHistory({ key, status: 'loading' });
|
||||
try {
|
||||
const result = await git.getGitLog(directory, { maxCount: 50, to: branch ? `refs/heads/${branch}` : 'HEAD' });
|
||||
if (requestId.current !== id || getRuntimeKey() !== requestRuntime) return;
|
||||
const commits = result.all.slice(0, 50);
|
||||
setHistory({ key, status: 'ready', commits });
|
||||
if (!useCommitSelectionStore.getState().selections.has(key)) {
|
||||
const initial = preferredHashRef.current
|
||||
? commits.find((commit) => commit.hash === preferredHashRef.current)
|
||||
: commits[0];
|
||||
if (initial) selectCommit(key, initial);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId.current !== id || getRuntimeKey() !== requestRuntime) return;
|
||||
setHistory({ key, status: 'error', message: error instanceof Error ? error.message : t('commitComparison.loadError') });
|
||||
}
|
||||
}, [branch, directory, enabled, git, key, selectCommit, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
return () => { requestId.current += 1; };
|
||||
}, [refresh]);
|
||||
|
||||
const current = history?.key === key ? history : null;
|
||||
return {
|
||||
selectedCommit,
|
||||
commits: current?.status === 'ready' ? current.commits : NO_COMMITS,
|
||||
loading: enabled && (!current || current.status === 'loading'),
|
||||
error: current?.status === 'error' ? current.message : null,
|
||||
refresh,
|
||||
select: (commit: GitLogEntry) => selectCommit(key, commit),
|
||||
};
|
||||
}
|
||||
@@ -917,11 +917,76 @@ html:not(.dark) .chat-scroll {
|
||||
}
|
||||
}
|
||||
|
||||
/* Assistant message footer: collapse text labels only on very narrow layouts. */
|
||||
@container message-footer (max-width: 16rem) {
|
||||
.message-footer__label {
|
||||
display: none;
|
||||
}
|
||||
/* Assistant message footer: the run's facts collapse by priority instead of
|
||||
wrapping. The model and the duration always stay — the model truncates only
|
||||
once even those two stop fitting — while the thinking effort, the agent and
|
||||
the timestamp drop out in that order as the row narrows. The optional group
|
||||
wraps its overflow onto a second line that its single-line height clips
|
||||
away, so a row that exists once per message costs no measurement. */
|
||||
.message-footer__facts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-footer__facts-core {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.message-footer__facts-optional {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
/* Takes only the width of its facts (no grow — growing would push the
|
||||
duration to the far edge and leave a hole after the agent) and gives that
|
||||
width up long before the model starts truncating. */
|
||||
flex: 0 9999 auto;
|
||||
max-height: 1.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* A hairline item holds the start of the first line so that EVERY fact can
|
||||
wrap off it. Without something already on the line, the browser keeps the
|
||||
first fact there and the clip slices it in half instead of dropping it. */
|
||||
.message-footer__facts-optional::before {
|
||||
content: '';
|
||||
flex: 0 0 1px;
|
||||
/* Full line height: it has to define the first line, or the facts that wrap
|
||||
past it land a pixel below and slip under the clip. */
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* The timestamp gives up its room before the agent and the effort do — it is
|
||||
the first fact the row can afford to lose (on touch it is not here at all:
|
||||
it lives in the actions sheet, where it can never be lost). */
|
||||
.message-footer__facts-optional--drops-first {
|
||||
flex: 0 99999 auto;
|
||||
}
|
||||
|
||||
/* The duration sits after the facts that can disappear and never shrinks. */
|
||||
.message-footer__facts-duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Each fact carries its own leading separator and spacing, so the group needs
|
||||
no gap that would offset the zero-width holder. */
|
||||
.message-footer__fact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding-inline-start: 0.375rem;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Animated tabs: collapse labels based on local container width. */
|
||||
|
||||
@@ -190,18 +190,22 @@ export interface GetGitDiffOptions {
|
||||
|
||||
/**
|
||||
* Diff between two refs. Uses three-dot (`base...head`) semantics server-side, so changes
|
||||
* pulled into `head` by merging `base` are excluded — only the branch's own work is returned.
|
||||
* pulled into `head` by merging `base` are excluded. Refs are used as selected.
|
||||
* includeWorkingTree compares that merge base with the checked-out branch's
|
||||
* current files, including staged, unstaged, and untracked changes.
|
||||
*/
|
||||
export interface GetGitRangeDiffOptions {
|
||||
base: string;
|
||||
head: string;
|
||||
path?: string;
|
||||
contextLines?: number;
|
||||
includeWorkingTree?: boolean;
|
||||
}
|
||||
|
||||
export interface GetGitRangeFilesOptions {
|
||||
base: string;
|
||||
head: string;
|
||||
includeWorkingTree?: boolean;
|
||||
}
|
||||
|
||||
/** One changed file in a `base...head` range, with its change letter (A/M/D/R/C). */
|
||||
@@ -388,6 +392,7 @@ export interface GitLogResponse {
|
||||
|
||||
export interface CommitFileEntry {
|
||||
path: string;
|
||||
previousPath?: string;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
isBinary: boolean;
|
||||
@@ -398,6 +403,13 @@ export interface GitCommitFilesResponse {
|
||||
files: CommitFileEntry[];
|
||||
}
|
||||
|
||||
export interface GetGitCommitDiffOptions {
|
||||
hash: string;
|
||||
path?: string;
|
||||
previousPath?: string;
|
||||
contextLines?: number;
|
||||
}
|
||||
|
||||
export interface CommitFileDiffResponse {
|
||||
original: string;
|
||||
modified: string;
|
||||
@@ -569,6 +581,7 @@ export interface GitAPI {
|
||||
renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>;
|
||||
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
|
||||
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
|
||||
getGitCommitDiff?(directory: string, options: GetGitCommitDiffOptions): Promise<GitDiffResponse>;
|
||||
getCommitFileDiff?(directory: string, hash: string, filePath: string, isBinary: boolean): Promise<CommitFileDiffResponse>;
|
||||
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
|
||||
hasLocalIdentity?(directory: string): Promise<boolean>;
|
||||
|
||||
@@ -984,6 +984,15 @@ export async function getCommitFiles(
|
||||
return gitHttp.getCommitFiles(directory, hash);
|
||||
}
|
||||
|
||||
export async function getGitCommitDiff(directory: string, options: import('./api/types').GetGitCommitDiffOptions): Promise<import('./api/types').GitDiffResponse> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) {
|
||||
if (!runtime.getGitCommitDiff) throw new Error('Commit comparisons are unavailable in this runtime');
|
||||
return runtime.getGitCommitDiff(directory, options);
|
||||
}
|
||||
return gitHttp.getGitCommitDiff(directory, options);
|
||||
}
|
||||
|
||||
export async function getCommitFileDiff(
|
||||
directory: string,
|
||||
hash: string,
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
deleteRemoteBranch,
|
||||
dropGitStash,
|
||||
getGitBranches,
|
||||
getGitRangeDiff,
|
||||
getGitRangeFiles,
|
||||
getGitCommitDiff,
|
||||
getCommitFiles,
|
||||
getGitLog,
|
||||
getGitStatus,
|
||||
gitFetch,
|
||||
merge,
|
||||
@@ -141,6 +146,74 @@ describe('gitApiHttp index mutations', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitApiHttp branch comparisons', () => {
|
||||
test('sends commit hashes and rename paths without trimming and rejects incomplete commit lists', async () => {
|
||||
installWindowMock();
|
||||
const urls: URL[] = [];
|
||||
globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
urls.push(url);
|
||||
return Response.json(url.pathname.endsWith('/commit-diff') ? { diff: 'commit patch' } : { files: [{ path: 'incomplete' }] });
|
||||
}, previousFetch);
|
||||
try {
|
||||
const hash = 'a'.repeat(40);
|
||||
expect(await getGitCommitDiff('/repo', { hash, path: ' new\nfile.ts', previousPath: 'old.ts', contextLines: 20 }))
|
||||
.toEqual({ diff: 'commit patch' });
|
||||
expect(urls[0].pathname).toBe('/api/git/commit-diff');
|
||||
expect(urls[0].searchParams.get('hash')).toBe(hash);
|
||||
expect(urls[0].searchParams.get('path')).toBe(' new\nfile.ts');
|
||||
expect(urls[0].searchParams.get('previousPath')).toBe('old.ts');
|
||||
expect(urls[0].searchParams.get('context')).toBe('20');
|
||||
await expect(getCommitFiles('/repo', hash)).rejects.toThrow();
|
||||
await expect(getGitLog('/repo', { maxCount: 50, to: 'refs/heads/feature' })).rejects.toThrow();
|
||||
expect(urls[2].searchParams.get('maxCount')).toBe('50');
|
||||
expect(urls[2].searchParams.get('to')).toBe('refs/heads/feature');
|
||||
expect(urls[2].searchParams.has('all')).toBe(false);
|
||||
} finally {
|
||||
restoreMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test('sends the exact selected refs and working-tree option to both range endpoints', async () => {
|
||||
installWindowMock();
|
||||
const urls: URL[] = [];
|
||||
globalThis.fetch = Object.assign(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input), 'http://localhost');
|
||||
urls.push(url);
|
||||
return Response.json(url.pathname.endsWith('/range-files')
|
||||
? { files: [{ path: 'new.ts', status: 'A' }] }
|
||||
: { diff: 'current patch' });
|
||||
}, previousFetch);
|
||||
try {
|
||||
const options = { base: 'refs/heads/parent', head: 'child', includeWorkingTree: true };
|
||||
expect(await getGitRangeDiff('/repo', options)).toEqual({ diff: 'current patch' });
|
||||
expect(await getGitRangeFiles('/repo', options)).toEqual([{ path: 'new.ts', status: 'A' }]);
|
||||
expect(urls).toHaveLength(2);
|
||||
for (const url of urls) {
|
||||
expect(url.searchParams.get('base')).toBe('refs/heads/parent');
|
||||
expect(url.searchParams.get('head')).toBe('child');
|
||||
expect(url.searchParams.get('includeWorkingTree')).toBe('true');
|
||||
}
|
||||
} finally {
|
||||
restoreMocks();
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects malformed file lists and preserves the server ref error', async () => {
|
||||
installWindowMock();
|
||||
globalThis.fetch = Object.assign(async () => Response.json({ files: [{ path: 'new.ts' }] }), previousFetch);
|
||||
const options = { base: 'missing', head: 'child', includeWorkingTree: true };
|
||||
try {
|
||||
await expect(getGitRangeFiles('/repo', options)).rejects.toThrow();
|
||||
globalThis.fetch = Object.assign(async () => Response.json({ error: 'Fetch the selected ref first.' }, { status: 500 }), previousFetch);
|
||||
await expect(getGitRangeDiff('/repo', options)).rejects.toThrow('Fetch the selected ref first.');
|
||||
await expect(getGitRangeFiles('/repo', options)).rejects.toThrow('Fetch the selected ref first.');
|
||||
} finally {
|
||||
restoreMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gitApiHttp status cache', () => {
|
||||
test('a Git refresh hint invalidates the cached status before listeners fetch', async () => {
|
||||
installWindowMock();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
GitStatus,
|
||||
GitDiffResponse,
|
||||
GetGitDiffOptions,
|
||||
GetGitRangeDiffOptions,
|
||||
GetGitRangeFilesOptions,
|
||||
GetGitCommitDiffOptions,
|
||||
GitFileDiffResponse,
|
||||
GetGitFileDiffOptions,
|
||||
GitBranch,
|
||||
@@ -43,6 +45,24 @@ import { getRuntimeKey } from './runtime-switch';
|
||||
import { notifyGitStatusInvalidated, subscribeGitStatusInvalidations } from './gitStatusInvalidation';
|
||||
|
||||
const API_BASE = '/api/git';
|
||||
const gitRangeDiffSchema = z.object({ diff: z.string() });
|
||||
const gitRangeFilesSchema = z.object({ files: z.array(z.object({ path: z.string(), status: z.string() })) });
|
||||
const gitRangeErrorSchema = z.object({ error: z.string() });
|
||||
const gitCommitFilesSchema = z.object({ files: z.array(z.object({
|
||||
path: z.string(), previousPath: z.string().optional(), changeType: z.string(),
|
||||
insertions: z.number(), deletions: z.number(), isBinary: z.boolean(),
|
||||
})) });
|
||||
const gitLogEntrySchema = z.object({
|
||||
hash: z.string(), date: z.string(), message: z.string(), refs: z.string(), body: z.string(),
|
||||
author_name: z.string(), author_email: z.string(), filesChanged: z.number(),
|
||||
insertions: z.number(), deletions: z.number(), parents: z.array(z.string()),
|
||||
});
|
||||
const gitLogSchema = z.object({ all: z.array(gitLogEntrySchema), latest: gitLogEntrySchema.nullable(), total: z.number() });
|
||||
|
||||
async function rangeResponseError(response: Response, fallback: string): Promise<Error> {
|
||||
const parsed = gitRangeErrorSchema.safeParse(await response.json().catch(() => null));
|
||||
return new Error(parsed.success ? parsed.data.error : `${fallback}: ${response.statusText}`);
|
||||
}
|
||||
const GIT_STATUS_CACHE_TTL_MS = 1200;
|
||||
const GIT_REPO_CHECK_CACHE_TTL_MS = 5000;
|
||||
const gitStatusCache = new Map<string, { value: GitStatus; expiresAt: number }>();
|
||||
@@ -285,7 +305,7 @@ export async function getGitRangeDiff(
|
||||
directory: string,
|
||||
options: GetGitRangeDiffOptions
|
||||
): Promise<GitDiffResponse> {
|
||||
const { base, head, path, contextLines } = options;
|
||||
const { base, head, path, contextLines, includeWorkingTree } = options;
|
||||
if (!base || !head) {
|
||||
throw new Error('base and head are required to fetch git range diff');
|
||||
}
|
||||
@@ -296,40 +316,46 @@ export async function getGitRangeDiff(
|
||||
head,
|
||||
path: path || undefined,
|
||||
context: contextLines,
|
||||
includeWorkingTree,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git range diff: ${response.statusText}`);
|
||||
throw await rangeResponseError(response, 'Failed to get git range diff');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
return gitRangeDiffSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
export async function getGitCommitDiff(directory: string, options: GetGitCommitDiffOptions): Promise<GitDiffResponse> {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-diff`, directory, {
|
||||
hash: options.hash,
|
||||
path: options.path,
|
||||
previousPath: options.previousPath,
|
||||
context: options.contextLines,
|
||||
}));
|
||||
if (!response.ok) throw await rangeResponseError(response, 'Failed to get commit diff');
|
||||
return gitRangeDiffSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
export async function getGitRangeFiles(
|
||||
directory: string,
|
||||
options: GetGitRangeFilesOptions
|
||||
): Promise<import('./api/types').GitRangeFileEntry[]> {
|
||||
const { base, head } = options;
|
||||
const { base, head, includeWorkingTree } = options;
|
||||
if (!base || !head) {
|
||||
throw new Error('base and head are required to fetch git range files');
|
||||
}
|
||||
|
||||
const response = await runtimeFetch(
|
||||
buildUrl(`${API_BASE}/range-files`, directory, { base, head })
|
||||
buildUrl(`${API_BASE}/range-files`, directory, { base, head, includeWorkingTree })
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git range files: ${response.statusText}`);
|
||||
throw await rangeResponseError(response, 'Failed to get git range files');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { files?: unknown };
|
||||
if (!Array.isArray(payload.files)) return [];
|
||||
return payload.files.filter((entry): entry is import('./api/types').GitRangeFileEntry => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const candidate = entry as { path?: unknown; status?: unknown };
|
||||
return typeof candidate.path === 'string' && typeof candidate.status === 'string';
|
||||
});
|
||||
return gitRangeFilesSchema.parse(await response.json()).files;
|
||||
}
|
||||
|
||||
export async function getBranchBase(
|
||||
@@ -944,7 +970,7 @@ export async function getGitLog(
|
||||
const errorBody = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(`Failed to get git log: ${errorBody.error || response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
return gitLogSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
export async function getCommitFiles(
|
||||
@@ -955,9 +981,9 @@ export async function getCommitFiles(
|
||||
buildUrl(`${API_BASE}/commit-files`, directory, { hash })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get commit files: ${response.statusText}`);
|
||||
throw await rangeResponseError(response, 'Failed to get commit files');
|
||||
}
|
||||
return response.json();
|
||||
return gitCommitFilesSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
export async function getCommitFileDiff(
|
||||
|
||||
@@ -3,6 +3,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
'commitComparison.mode': 'Commit',
|
||||
'commitComparison.select': 'Commit auswählen',
|
||||
'commitComparison.search': 'Commits suchen...',
|
||||
'commitComparison.loadError': 'Commits konnten nicht geladen werden',
|
||||
'commitComparison.noCommits': 'Keine Commits gefunden',
|
||||
'commitComparison.emptyDiff': 'Keine Änderungen in diesem Commit',
|
||||
'chat.liveActivity.title': 'Aktivität',
|
||||
'chat.liveActivity.changedFile': '{count} Datei geändert',
|
||||
'chat.liveActivity.changedFiles': '{count} Dateien geändert',
|
||||
@@ -2094,6 +2100,8 @@ export const dict = {
|
||||
'chat.messageBody.actions.openPreviewAria': 'Vorschau öffnen',
|
||||
'chat.messageBody.actions.openPreview': 'Vorschau öffnen',
|
||||
'chat.messageBody.actions.copyAnswer': 'Antwort kopieren',
|
||||
'chat.messageBody.actions.moreActions': 'Weitere Aktionen',
|
||||
'chat.messageBody.toast.copied': 'In die Zwischenablage kopiert',
|
||||
'chat.messageBody.actions.savingImage': 'Bild wird gespeichert...',
|
||||
'chat.messageBody.actions.saveAsImage': 'Als Bild speichern',
|
||||
'chat.messageBody.actions.saveAsPlan': 'Als Plan speichern',
|
||||
|
||||
@@ -3,6 +3,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
'commitComparison.mode': 'Commit',
|
||||
'commitComparison.select': 'Select commit',
|
||||
'commitComparison.search': 'Search commits...',
|
||||
'commitComparison.loadError': 'Failed to load commits',
|
||||
'commitComparison.noCommits': 'No commits found',
|
||||
'commitComparison.emptyDiff': 'No changes in this commit',
|
||||
'chat.liveActivity.title': 'Activity',
|
||||
'chat.liveActivity.changedFile': 'Changed {count} file',
|
||||
'chat.liveActivity.changedFiles': 'Changed {count} files',
|
||||
@@ -2314,6 +2320,8 @@ export const dict = {
|
||||
'chat.messageBody.actions.openPreviewAria': 'Open preview',
|
||||
'chat.messageBody.actions.openPreview': 'Open preview',
|
||||
'chat.messageBody.actions.copyAnswer': 'Copy answer',
|
||||
'chat.messageBody.actions.moreActions': 'More actions',
|
||||
'chat.messageBody.toast.copied': 'Copied to clipboard',
|
||||
'chat.messageBody.actions.savingImage': 'Saving image...',
|
||||
'chat.messageBody.actions.saveAsImage': 'Save as image',
|
||||
'chat.messageBody.actions.saveAsPlan': 'Save as plan',
|
||||
|
||||
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'commitComparison.mode': 'Commit',
|
||||
'commitComparison.select': 'Seleccionar commit',
|
||||
'commitComparison.search': 'Buscar commits...',
|
||||
'commitComparison.loadError': 'No se pudieron cargar los commits',
|
||||
'commitComparison.noCommits': 'No se encontraron commits',
|
||||
'commitComparison.emptyDiff': 'No hay cambios en este commit',
|
||||
'chat.liveActivity.title': 'Actividad',
|
||||
'chat.liveActivity.changedFile': '{count} archivo modificado',
|
||||
'chat.liveActivity.changedFiles': '{count} archivos modificados',
|
||||
@@ -2292,6 +2298,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.openPreviewAria": "Abrir vista previa",
|
||||
"chat.messageBody.actions.openPreview": "Abrir vista previa",
|
||||
"chat.messageBody.actions.copyAnswer": "Copiar respuesta",
|
||||
"chat.messageBody.actions.moreActions": "Más acciones",
|
||||
"chat.messageBody.toast.copied": "Copiado al portapapeles",
|
||||
"chat.messageBody.actions.savingImage": "Guardando imagen...",
|
||||
"chat.messageBody.actions.saveAsImage": "Guardar como imagen",
|
||||
"chat.messageBody.actions.saveAsPlan": "Guardar como plan",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
'commitComparison.mode': 'Commit',
|
||||
'commitComparison.select': 'Choisir un commit',
|
||||
'commitComparison.search': 'Rechercher des commits...',
|
||||
'commitComparison.loadError': 'Impossible de charger les commits',
|
||||
'commitComparison.noCommits': 'Aucun commit trouvé',
|
||||
'commitComparison.emptyDiff': 'Aucune modification dans ce commit',
|
||||
'chat.liveActivity.title': 'Activité',
|
||||
'chat.liveActivity.changedFile': '{count} fichier modifié',
|
||||
'chat.liveActivity.changedFiles': '{count} fichiers modifiés',
|
||||
@@ -2037,6 +2043,8 @@ export const dict = {
|
||||
'chat.messageBody.actions.openPreviewAria': 'Ouvrir l\'aperçu',
|
||||
'chat.messageBody.actions.openPreview': 'Ouvrir l\'aperçu',
|
||||
'chat.messageBody.actions.copyAnswer': 'Copier la réponse',
|
||||
'chat.messageBody.actions.moreActions': 'Plus d’actions',
|
||||
'chat.messageBody.toast.copied': 'Copié dans le presse-papiers',
|
||||
'chat.messageBody.actions.savingImage': 'Enregistrement de l\'image...',
|
||||
'chat.messageBody.actions.saveAsImage': 'Enregistrer sous image',
|
||||
'chat.messageBody.actions.saveAsPlan': 'Enregistrer comme forfait',
|
||||
|
||||
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'commitComparison.mode': 'コミット',
|
||||
'commitComparison.select': 'コミットを選択',
|
||||
'commitComparison.search': 'コミットを検索...',
|
||||
'commitComparison.loadError': 'コミットを読み込めませんでした',
|
||||
'commitComparison.noCommits': 'コミットが見つかりません',
|
||||
'commitComparison.emptyDiff': 'このコミットに変更はありません',
|
||||
'chat.liveActivity.title': 'アクティビティ',
|
||||
'chat.liveActivity.changedFile': '{count} ファイルを変更',
|
||||
'chat.liveActivity.changedFiles': '{count} ファイルを変更',
|
||||
@@ -2310,6 +2316,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.openPreviewAria': 'プレビューを開く',
|
||||
'chat.messageBody.actions.openPreview': 'プレビューを開く',
|
||||
'chat.messageBody.actions.copyAnswer': '回答をコピー',
|
||||
'chat.messageBody.actions.moreActions': 'その他の操作',
|
||||
'chat.messageBody.toast.copied': 'クリップボードにコピーしました',
|
||||
'chat.messageBody.actions.savingImage': '画像を保存中...',
|
||||
'chat.messageBody.actions.saveAsImage': '画像として保存',
|
||||
'chat.messageBody.actions.saveAsPlan': '計画として保存',
|
||||
|
||||
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'commitComparison.mode': '커밋',
|
||||
'commitComparison.select': '커밋 선택',
|
||||
'commitComparison.search': '커밋 검색...',
|
||||
'commitComparison.loadError': '커밋을 불러오지 못했습니다',
|
||||
'commitComparison.noCommits': '커밋을 찾을 수 없습니다',
|
||||
'commitComparison.emptyDiff': '이 커밋에는 변경 사항이 없습니다',
|
||||
'chat.liveActivity.title': '활동',
|
||||
'chat.liveActivity.changedFile': '파일 {count}개 변경',
|
||||
'chat.liveActivity.changedFiles': '파일 {count}개 변경',
|
||||
@@ -2314,6 +2320,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.unpinContext': '컨텍스트에서 고정 해제(압축 후 유지되지 않음)',
|
||||
'chat.messageBody.actions.contextPinFailed': '컨텍스트 고정을 업데이트하지 못했습니다',
|
||||
'chat.messageBody.actions.copyAnswer': '답변 복사',
|
||||
'chat.messageBody.actions.moreActions': '추가 작업',
|
||||
'chat.messageBody.toast.copied': '클립보드에 복사됨',
|
||||
'chat.messageBody.actions.savingImage': '이미지 저장 중…',
|
||||
'chat.messageBody.actions.saveAsImage': '이미지로 저장',
|
||||
'chat.messageBody.actions.saveAsPlan': '플랜으로 저장',
|
||||
|
||||
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'commitComparison.mode': 'Commit',
|
||||
'commitComparison.select': 'Wybierz commit',
|
||||
'commitComparison.search': 'Szukaj commitów...',
|
||||
'commitComparison.loadError': 'Nie udało się wczytać commitów',
|
||||
'commitComparison.noCommits': 'Nie znaleziono commitów',
|
||||
'commitComparison.emptyDiff': 'Brak zmian w tym commicie',
|
||||
'chat.liveActivity.title': 'Aktywność',
|
||||
'chat.liveActivity.changedFile': 'Zmieniono {count} plik',
|
||||
'chat.liveActivity.changedFiles': 'Zmienione pliki: {count}',
|
||||
@@ -963,6 +969,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.openPreviewAria': 'Otwórz podgląd',
|
||||
'chat.messageBody.actions.openPreview': 'Otwórz podgląd',
|
||||
'chat.messageBody.actions.copyAnswer': 'Kopiuj odpowiedź',
|
||||
'chat.messageBody.actions.moreActions': 'Więcej akcji',
|
||||
'chat.messageBody.toast.copied': 'Skopiowano do schowka',
|
||||
'chat.messageBody.actions.savingImage': 'Zapisywanie obrazu...',
|
||||
'chat.messageBody.actions.saveAsImage': 'Zapisz jako obraz',
|
||||
'chat.messageBody.actions.saveAsPlan': 'Zapisz jako plan',
|
||||
|
||||
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'commitComparison.mode': 'Commit',
|
||||
'commitComparison.select': 'Selecionar commit',
|
||||
'commitComparison.search': 'Buscar commits...',
|
||||
'commitComparison.loadError': 'Não foi possível carregar os commits',
|
||||
'commitComparison.noCommits': 'Nenhum commit encontrado',
|
||||
'commitComparison.emptyDiff': 'Nenhuma alteração neste commit',
|
||||
'chat.liveActivity.title': 'Atividade',
|
||||
'chat.liveActivity.changedFile': '{count} arquivo alterado',
|
||||
'chat.liveActivity.changedFiles': '{count} arquivos alterados',
|
||||
@@ -2292,6 +2298,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.openPreviewAria": "Abrir visualização",
|
||||
"chat.messageBody.actions.openPreview": "Abrir visualização",
|
||||
"chat.messageBody.actions.copyAnswer": "Copiar resposta",
|
||||
"chat.messageBody.actions.moreActions": "Mais ações",
|
||||
"chat.messageBody.toast.copied": "Copiado para a área de transferência",
|
||||
"chat.messageBody.actions.savingImage": "Salvando imagem...",
|
||||
"chat.messageBody.actions.saveAsImage": "Salvar como imagem",
|
||||
"chat.messageBody.actions.saveAsPlan": "Salvar como plano",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict = {
|
||||
'commitComparison.mode': 'Commit',
|
||||
'commitComparison.select': 'Commit seç',
|
||||
'commitComparison.search': 'Commit ara...',
|
||||
'commitComparison.loadError': 'Commitler yüklenemedi',
|
||||
'commitComparison.noCommits': 'Commit bulunamadı',
|
||||
'commitComparison.emptyDiff': 'Bu committe değişiklik yok',
|
||||
'chat.liveActivity.title': 'Etkinlik',
|
||||
'chat.liveActivity.changedFile': '{count} dosya değiştirildi',
|
||||
'chat.liveActivity.changedFiles': '{count} dosya değiştirildi',
|
||||
@@ -2251,6 +2257,8 @@ export const dict = {
|
||||
'chat.messageBody.actions.openPreviewAria': 'Önizlemeyi aç',
|
||||
'chat.messageBody.actions.openPreview': 'Önizlemeyi aç',
|
||||
'chat.messageBody.actions.copyAnswer': 'Yanıtı kopyala',
|
||||
'chat.messageBody.actions.moreActions': 'Diğer işlemler',
|
||||
'chat.messageBody.toast.copied': 'Panoya kopyalandı',
|
||||
'chat.messageBody.actions.savingImage': 'Görsel kaydediliyor...',
|
||||
'chat.messageBody.actions.saveAsImage': 'Görsel olarak kaydet',
|
||||
'chat.messageBody.actions.saveAsPlan': 'Plan olarak kaydet',
|
||||
|
||||
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'commitComparison.mode': 'Коміт',
|
||||
'commitComparison.select': 'Вибрати коміт',
|
||||
'commitComparison.search': 'Пошук комітів...',
|
||||
'commitComparison.loadError': 'Не вдалося завантажити коміти',
|
||||
'commitComparison.noCommits': 'Комітів не знайдено',
|
||||
'commitComparison.emptyDiff': 'У цьому коміті немає змін',
|
||||
'chat.liveActivity.title': 'Дії',
|
||||
'chat.liveActivity.changedFile': 'Змінено {count} файл',
|
||||
'chat.liveActivity.changedFiles': 'Змінено файлів: {count}',
|
||||
@@ -2292,6 +2298,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.actions.openPreviewAria": "Відкрити попередній перегляд",
|
||||
"chat.messageBody.actions.openPreview": "Відкрити попередній перегляд",
|
||||
"chat.messageBody.actions.copyAnswer": "Скопіювати відповідь",
|
||||
"chat.messageBody.actions.moreActions": "Більше дій",
|
||||
"chat.messageBody.toast.copied": "Скопійовано в буфер обміну",
|
||||
"chat.messageBody.actions.savingImage": "Збереження зображення...",
|
||||
"chat.messageBody.actions.saveAsImage": "Зберегти як зображення",
|
||||
"chat.messageBody.actions.saveAsPlan": "Зберегти як план",
|
||||
|
||||
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'commitComparison.mode': '提交',
|
||||
'commitComparison.select': '选择提交',
|
||||
'commitComparison.search': '搜索提交...',
|
||||
'commitComparison.loadError': '无法加载提交',
|
||||
'commitComparison.noCommits': '未找到提交',
|
||||
'commitComparison.emptyDiff': '此提交没有更改',
|
||||
'chat.liveActivity.title': '活动',
|
||||
'chat.liveActivity.changedFile': '更改了 {count} 个文件',
|
||||
'chat.liveActivity.changedFiles': '更改了 {count} 个文件',
|
||||
@@ -2280,6 +2286,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.openPreviewAria': '打开预览',
|
||||
'chat.messageBody.actions.openPreview': '打开预览',
|
||||
'chat.messageBody.actions.copyAnswer': '复制回答',
|
||||
'chat.messageBody.actions.moreActions': '更多操作',
|
||||
'chat.messageBody.toast.copied': '已复制到剪贴板',
|
||||
'chat.messageBody.actions.savingImage': '正在保存图片...',
|
||||
'chat.messageBody.actions.saveAsImage': '保存为图片',
|
||||
'chat.messageBody.actions.saveAsPlan': '保存为计划',
|
||||
|
||||
@@ -4,6 +4,12 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
|
||||
import { linearPanelI18n } from './linear-panel.i18n';
|
||||
|
||||
export const dict: Record<I18nKey, string> = {
|
||||
'commitComparison.mode': '提交',
|
||||
'commitComparison.select': '選擇提交',
|
||||
'commitComparison.search': '搜尋提交...',
|
||||
'commitComparison.loadError': '無法載入提交',
|
||||
'commitComparison.noCommits': '找不到提交',
|
||||
'commitComparison.emptyDiff': '此提交沒有變更',
|
||||
'chat.liveActivity.title': '活動',
|
||||
'chat.liveActivity.changedFile': '變更了 {count} 個檔案',
|
||||
'chat.liveActivity.changedFiles': '變更了 {count} 個檔案',
|
||||
@@ -2284,6 +2290,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.messageBody.actions.openPreviewAria': '開啟預覽',
|
||||
'chat.messageBody.actions.openPreview': '開啟預覽',
|
||||
'chat.messageBody.actions.copyAnswer': '複製回答',
|
||||
'chat.messageBody.actions.moreActions': '更多操作',
|
||||
'chat.messageBody.toast.copied': '已複製到剪貼簿',
|
||||
'chat.messageBody.actions.savingImage': '正在儲存圖片...',
|
||||
'chat.messageBody.actions.saveAsImage': '儲存為圖片',
|
||||
'chat.messageBody.actions.saveAsPlan': '儲存為計畫',
|
||||
|
||||
@@ -11,6 +11,7 @@ export type WalkthroughWorkingTreeScope = 'all' | 'staged' | 'working';
|
||||
export type WalkthroughSource =
|
||||
| { kind: 'working-tree'; scope: WalkthroughWorkingTreeScope }
|
||||
| { kind: 'branch'; baseRef: string; headRef: string }
|
||||
| { kind: 'commit'; hash: string }
|
||||
| { kind: 'pr'; number: number };
|
||||
|
||||
export type WalkthroughChapterIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'doc' | 'gear';
|
||||
|
||||
@@ -42,6 +42,15 @@ refresh attempt per opening, so a failed first load cannot create a retry loop.
|
||||
|
||||
### UI state stores
|
||||
|
||||
`useCommitSelectionStore.ts` shares the selected commit between Changes and
|
||||
walkthrough. Choices are session-only and keyed by runtime, directory, and
|
||||
checked-out branch, with at most 100 remembered choices. The picker history
|
||||
belongs to `useCommitComparison`, loads only while Commit mode is active, and
|
||||
is limited to the latest 50 commits. History failure stays distinct from an
|
||||
empty list; stale directory/runtime requests cannot replace current history or
|
||||
selection. A refreshed list preserves an explicit selection even when newer
|
||||
commits have pushed it beyond the latest 50.
|
||||
|
||||
Examples:
|
||||
|
||||
- `useUIStore.ts`
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { afterEach, expect, test } from 'bun:test';
|
||||
import { commitSelectionKey, useCommitSelectionStore } from './useCommitSelectionStore';
|
||||
|
||||
afterEach(() => useCommitSelectionStore.setState({ selections: new Map() }));
|
||||
|
||||
test('shares a commit choice within its runtime, repository and checked-out branch only', () => {
|
||||
const key = commitSelectionKey('/repo', 'feature', 'runtime-a');
|
||||
const commit = {
|
||||
hash: 'a'.repeat(40), message: 'Selected commit', date: '2026-09-09T09:22:00Z',
|
||||
author_name: 'Test Author', author_email: 'test@example.com', refs: '', body: '',
|
||||
filesChanged: 1, insertions: 1, deletions: 0, parents: [],
|
||||
};
|
||||
useCommitSelectionStore.getState().select(key, commit);
|
||||
expect(useCommitSelectionStore.getState().selections.get(key)).toEqual(commit);
|
||||
for (const otherKey of [
|
||||
commitSelectionKey('/other', 'feature', 'runtime-a'),
|
||||
commitSelectionKey('/repo', 'other', 'runtime-a'),
|
||||
commitSelectionKey('/repo', 'feature', 'runtime-b'),
|
||||
]) expect(useCommitSelectionStore.getState().selections.get(otherKey)).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { create } from 'zustand';
|
||||
import type { GitLogEntry } from '@/lib/api/types';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
export const commitSelectionKey = (directory: string, branch: string | null, runtimeKey = getRuntimeKey()): string =>
|
||||
JSON.stringify([runtimeKey, directory, branch]);
|
||||
|
||||
interface CommitSelectionState {
|
||||
selections: Map<string, GitLogEntry>;
|
||||
select: (key: string, commit: GitLogEntry) => void;
|
||||
}
|
||||
|
||||
// Shared between Changes and walkthrough. Selections are session-only and the
|
||||
// branch is part of the key, so a checkout starts with that branch's history.
|
||||
export const useCommitSelectionStore = create<CommitSelectionState>((set) => ({
|
||||
selections: new Map(),
|
||||
select: (key, commit) => set((state) => {
|
||||
const selections = new Map(state.selections);
|
||||
selections.delete(key);
|
||||
selections.set(key, commit);
|
||||
// Bound remembered choices on explicit selection, never while acquiring a view.
|
||||
if (selections.size > 100) {
|
||||
const oldest = selections.keys().next().value;
|
||||
if (oldest !== undefined) selections.delete(oldest);
|
||||
}
|
||||
return { selections };
|
||||
}),
|
||||
}));
|
||||
@@ -13,6 +13,13 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('useUIStore context panel tabs', () => {
|
||||
test('preserves Commit mode when context tabs are normalized', () => {
|
||||
useUIStore.getState().openContextPanelTab('/repo', { mode: 'diff', diffScope: 'commit' });
|
||||
useUIStore.getState().openContextPanelTab('/repo', { mode: 'file', targetPath: '/repo/README.md' });
|
||||
const diffTab = getContextPanelTabs('/repo').find((tab) => tab.mode === 'diff');
|
||||
expect(diffTab?.diffScope).toBe('commit');
|
||||
});
|
||||
|
||||
test('updates readOnly when an existing chat tab is reopened', () => {
|
||||
const directory = '/repo';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { isWindowsArm64 } from '@/lib/platform';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
|
||||
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch' | 'commit';
|
||||
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
|
||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
@@ -280,7 +280,7 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
|
||||
};
|
||||
|
||||
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
|
||||
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null;
|
||||
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' || value === 'commit' ? value : null;
|
||||
};
|
||||
|
||||
/** A plan tab's owner must be a complete project reference or nothing; a
|
||||
|
||||
@@ -168,6 +168,18 @@ describe('useWalkthroughStore — model selection', () => {
|
||||
expect(useWalkthroughStore.getState().getSelectedModel('/repo', SOURCE))
|
||||
.toBe('anthropic/claude-haiku-4-5');
|
||||
});
|
||||
|
||||
test('keeps commit walkthroughs separate and selecting one does not generate', () => {
|
||||
const generatedBefore = generateCalls;
|
||||
const first: WalkthroughSource = { kind: 'commit', hash: 'a'.repeat(40) };
|
||||
const second: WalkthroughSource = { kind: 'commit', hash: 'b'.repeat(40) };
|
||||
useWalkthroughStore.getState().selectModel('/repo', first, 'anthropic/claude-haiku-4-5');
|
||||
useWalkthroughStore.getState().requestSource('/repo', second);
|
||||
expect(useWalkthroughStore.getState().getSelectedModel('/repo', first)).toBe('anthropic/claude-haiku-4-5');
|
||||
expect(useWalkthroughStore.getState().getSelectedModel('/repo', second)).toBeUndefined();
|
||||
expect(useWalkthroughStore.getState().requestedSource['/repo']).toEqual(second);
|
||||
expect(generateCalls).toBe(generatedBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useWalkthroughStore — walkthrough language', () => {
|
||||
|
||||
Binary file not shown.
@@ -26,8 +26,10 @@ The following functions are exported and used by the web server:
|
||||
### Status and Diff Operations
|
||||
- `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state.
|
||||
- `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree. Untracked symbolic links are represented as link entries without following their targets.
|
||||
- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs. Uses three-dot `base...head` semantics, so work merged into `head` from `base` is excluded and only the branch's own changes are returned. Prefers `origin/<base>` when that remote-tracking ref exists, so a stale local base branch does not resurface already-merged commits. Exposed as `GET /api/git/range-diff` (`path` optional; omit it for the whole range).
|
||||
- `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs.
|
||||
- `getRangeDiff(directory, { base, head, path, contextLines, includeWorkingTree })`: Compare the merge base of the exact selected refs with `head`. With `includeWorkingTree: true`, compare with the checked-out branch's current files instead, including committed, staged, unstaged, and untracked work in one net diff. This mode rejects a head that is not the checked-out branch. Exposed as `GET /api/git/range-diff`; omit `path` for the whole comparison.
|
||||
- `getRangeFiles(directory, { base, head, includeWorkingTree })`: List changed paths using the same comparison as `getRangeDiff`. A successful empty list means the final files match the merge base, even if staging and working-tree changes cancel each other out.
|
||||
- Both range operations honor refs literally. A local `main` is never replaced with `origin/main`, and an unavailable ref fails rather than choosing a different remote. The UI picker sends qualified refs to distinguish local and remote branches with matching display names.
|
||||
- Working-tree comparisons use the real index read-only. When untracked paths exist, a temporary copy of the index receives intent-to-add entries so Git computes additions, deletions, recreations, and renames together. Current contents come from the working tree, symlinks remain links, ignored files stay excluded, and temporary files are removed on success or failure.
|
||||
- `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text).
|
||||
- `listUntrackedPaths(directory)`: List individual untracked file paths honoring ignore rules. Much cheaper than `getStatus` when that is all a caller needs. Deliberately not `--directory`: collapsed directory entries end in a slash and are rejected by the per-file diff helpers, so a caller would silently lose every file inside a new directory.
|
||||
- `getUntrackedDiffs(directory, filePaths, { concurrency, contextLines })`: Diffs for untracked files against an empty tree. Resolves the repository context once instead of per file (`getDiff` re-resolves every call, costing an extra `rev-parse` each time) and bounds how many diff processes run at once. Returns one entry per input path in order; unreadable paths yield `''` rather than failing the batch.
|
||||
@@ -38,6 +40,7 @@ The following functions are exported and used by the web server:
|
||||
- `applyHunk(directory, filePath, options)`: Apply a single-hunk patch via `git apply`. `options.action` is `stage` (`git apply --cached`), `unstage` (`git apply --cached --reverse`), or `discard` (`git apply --reverse` in the working tree). The patch is written to a temp file; a `--check` runs first so a stale hunk fails with a clear "refresh and try again" error instead of a partial mutation. The patch target path must match the requested file.
|
||||
|
||||
### Branch Operations
|
||||
- `getBranchBase(directory, branch)`: Read a named creation source from reflog. After a rebase, the creation source is no longer a current parent record, so return `null` and let the user choose a base. Explicit per-runtime, directory, and branch choices in the shared UI outrank detection.
|
||||
- `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches).
|
||||
- `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream.
|
||||
- `createBranch(directory, branchName, options)`: Create and checkout a new branch.
|
||||
@@ -71,7 +74,8 @@ bootstrap, tracking is left unset rather than writing `branch.*.remote` /
|
||||
|
||||
### Log Operations
|
||||
- `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
|
||||
- `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit.
|
||||
- `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit relative to its first parent, or the empty tree for a root commit. NUL-delimited paths preserve whitespace; renamed files return their destination in `path` and source in `previousPath`.
|
||||
- `getCommitDiff(directory, { hash, path, previousPath, contextLines })`: Get the same commit's patch, with optional file filtering and context depth. `previousPath` keeps a rename's old and new paths in the per-file patch. Reads committed objects only, never the working tree. Exposed as `GET /api/git/commit-diff`; an unavailable hash fails rather than returning an empty diff.
|
||||
- `getCommitFileDiff(directory, hash, filePath, isBinary)`: Get before/after content for a specific file in a commit. Returns `{ original, modified, isBinary }`. Runs `git show <hash>^:<path>` and `git show <hash>:<path>` in parallel; returns empty strings on failure (added/deleted/root-commit edge cases).
|
||||
|
||||
### Merge and Rebase Operations
|
||||
@@ -130,6 +134,7 @@ The following functions are internal helpers used by exported functions:
|
||||
|
||||
### Runtime availability of range diffs
|
||||
- `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code.
|
||||
- Commit comparison uses the same server boundary through optional `GitAPI.getGitCommitDiff`. The shared Changes toolbar and walkthrough expose it on their existing desktop/tablet surfaces. The phone-specific Changes surface and the VS Code Git bridge keep their existing modes; Commit mode is not offered there. The HTTP operation is available to web, Electron, hosted mobile, and Capacitor clients.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -7,13 +7,13 @@ export function registerGitRoutes(app) {
|
||||
return gitLibraries;
|
||||
};
|
||||
|
||||
const resolveDirectoryQuery = (value) => {
|
||||
const resolveDirectoryQuery = (value, preserveWhitespace = false) => {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
const normalized = preserveWhitespace ? raw : raw.trim();
|
||||
return normalized || null;
|
||||
};
|
||||
|
||||
const extractGitErrorText = (error) => {
|
||||
@@ -417,6 +417,7 @@ export function registerGitRoutes(app) {
|
||||
const diff = await getRangeDiff(directory, {
|
||||
base,
|
||||
head,
|
||||
includeWorkingTree: req.query.includeWorkingTree === 'true',
|
||||
path: pathParam,
|
||||
contextLines: Number.isFinite(context) ? context : 3,
|
||||
});
|
||||
@@ -463,7 +464,7 @@ export function registerGitRoutes(app) {
|
||||
return res.status(400).json({ error: 'base and head parameters are required' });
|
||||
}
|
||||
|
||||
const files = await getRangeFiles(directory, { base, head });
|
||||
const files = await getRangeFiles(directory, { base, head, includeWorkingTree: req.query.includeWorkingTree === 'true' });
|
||||
res.json({ files });
|
||||
} catch (error) {
|
||||
console.error('Failed to get git range files:', error);
|
||||
@@ -1300,6 +1301,25 @@ export function registerGitRoutes(app) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/commit-diff', async (req, res) => {
|
||||
const { getCommitDiff } = await getGitLibraries();
|
||||
try {
|
||||
const directory = resolveDirectoryQuery(req.query.directory);
|
||||
const hash = resolveDirectoryQuery(req.query.hash);
|
||||
if (!directory || !hash) return res.status(400).json({ error: 'directory and hash are required' });
|
||||
const context = Number(req.query.context ?? 3);
|
||||
const diff = await getCommitDiff(directory, {
|
||||
hash,
|
||||
path: resolveDirectoryQuery(req.query.path, true) ?? undefined,
|
||||
previousPath: resolveDirectoryQuery(req.query.previousPath, true) ?? undefined,
|
||||
contextLines: Number.isFinite(context) ? context : 3,
|
||||
});
|
||||
res.json({ diff });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message || 'Failed to get commit diff' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/git/commit-file-diff', async (req, res) => {
|
||||
const { getCommitFileDiff } = await getGitLibraries();
|
||||
try {
|
||||
|
||||
@@ -2628,7 +2628,51 @@ async function assertRangeRefsResolve(git, refs) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) {
|
||||
// A private index lets git include untracked paths in the same tree comparison
|
||||
// as tracked files, including a staged deletion recreated at the same path.
|
||||
// Intent-to-add records only their existence; diff reads current file contents.
|
||||
async function runWorkingTreeRangeDiff(context, baseRef, headRef, args, paths = []) {
|
||||
const { git, repoRoot } = context;
|
||||
const readHead = async () => {
|
||||
const commit = (await git.raw(['rev-parse', '--verify', 'HEAD'])).trim();
|
||||
const ref = (await git.raw(['symbolic-ref', '--quiet', 'HEAD'])).trim();
|
||||
return `${commit}\n${ref}`;
|
||||
};
|
||||
const startingHead = await readHead();
|
||||
const [headCommit, currentRef] = startingHead.split('\n');
|
||||
const requestedRef = (await git.raw(['rev-parse', '--verify', '--symbolic-full-name', '--end-of-options', headRef])).trim();
|
||||
if (requestedRef !== currentRef) {
|
||||
throw new Error('Working-tree comparisons require the checked-out branch. Refresh and try again.');
|
||||
}
|
||||
const mergeBase = (await git.raw(['merge-base', baseRef, headCommit])).trim();
|
||||
const readDiff = async (comparisonGit) => {
|
||||
const diff = await comparisonGit.raw([...args, mergeBase, '--', ...paths]);
|
||||
if (await readHead() !== startingHead) {
|
||||
throw new Error('The checked-out branch changed during comparison. Refresh and try again.');
|
||||
}
|
||||
return diff;
|
||||
};
|
||||
const untracked = await git.raw(['ls-files', '--others', '--exclude-standard', '-z', '--', ...paths]);
|
||||
if (!untracked) return readDiff(git);
|
||||
|
||||
const temporaryDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'openchamber-branch-diff-'));
|
||||
try {
|
||||
const indexPath = (await git.raw(['rev-parse', '--git-path', 'index'])).trim();
|
||||
const temporaryIndex = path.join(temporaryDirectory, 'index');
|
||||
await fsp.copyFile(path.resolve(repoRoot, indexPath), temporaryIndex);
|
||||
const pathspecFile = path.join(temporaryDirectory, 'paths');
|
||||
await fsp.writeFile(pathspecFile, untracked);
|
||||
const comparisonGit = await createGit(repoRoot);
|
||||
comparisonGit.env('GIT_INDEX_FILE', temporaryIndex);
|
||||
comparisonGit.env('GIT_LITERAL_PATHSPECS', '1');
|
||||
await comparisonGit.raw(['add', '--intent-to-add', '--pathspec-from-file=' + pathspecFile, '--pathspec-file-nul']);
|
||||
return await readDiff(comparisonGit);
|
||||
} finally {
|
||||
await fsp.rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3, includeWorkingTree = false } = {}) {
|
||||
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
const headRef = typeof head === 'string' ? head.trim() : '';
|
||||
@@ -2636,51 +2680,39 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
|
||||
throw new Error('base and head are required');
|
||||
}
|
||||
|
||||
// Prefer remote-tracking base ref so merged commits don't reappear
|
||||
// when local base branch is stale (common when user stays on feature branch).
|
||||
let resolvedBase = baseRef;
|
||||
const originCandidate = `refs/remotes/origin/${baseRef}`;
|
||||
try {
|
||||
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
|
||||
if (verified && verified.trim()) {
|
||||
resolvedBase = `origin/${baseRef}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Not every repository has an `origin`. When the base names a branch that
|
||||
// exists only on another remote, a bare name does not resolve — git looks in
|
||||
// refs/heads, not across remotes — and the diff fails with "ambiguous
|
||||
// argument". Fall back to whichever remote actually carries it.
|
||||
if (resolvedBase === baseRef && !/[*?[\]^~:\\]/.test(baseRef)) {
|
||||
const resolvesLocally = await git
|
||||
.raw(['rev-parse', '--verify', `refs/heads/${baseRef}`])
|
||||
.then((value) => Boolean(String(value || '').trim()))
|
||||
.catch(() => false);
|
||||
|
||||
if (!resolvesLocally) {
|
||||
const remoteMatch = await git
|
||||
.raw(['for-each-ref', '--count=1', '--format=%(refname:short)', `refs/remotes/*/${baseRef}`])
|
||||
.then((value) => String(value || '').trim())
|
||||
.catch(() => '');
|
||||
if (remoteMatch) {
|
||||
resolvedBase = remoteMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
|
||||
await assertRangeRefsResolve(git, [baseRef, headRef]);
|
||||
|
||||
const args = ['diff', '--no-color'];
|
||||
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
|
||||
args.push(`-U${Math.max(0, contextLines)}`);
|
||||
}
|
||||
args.push(`${resolvedBase}...${headRef}`);
|
||||
const paths = [];
|
||||
if (filePath) {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
args.push('--', fileContext.repoPath);
|
||||
try {
|
||||
const fileContext = await resolveGitFileContext(directoryPath, directoryGit, filePath, repoRoot);
|
||||
paths.push(fileContext.repoPath);
|
||||
} catch (error) {
|
||||
if (error.message !== 'Invalid file path') throw error;
|
||||
// A committed deletion is absent from HEAD, the index, and the working
|
||||
// tree. It is still a valid range path when it exists at the merge base.
|
||||
const mergeBase = (await git.raw(['merge-base', baseRef, headRef])).trim();
|
||||
for (const root of new Set([repoRoot, directoryPath])) {
|
||||
const target = path.resolve(root, filePath);
|
||||
if (!isInsideOrSameDirectory(repoRoot, target)) continue;
|
||||
const repoPath = toGitPath(path.relative(repoRoot, target));
|
||||
const exists = await git.raw(['cat-file', '-e', `${mergeBase}:${repoPath}`]).then(() => true).catch(() => false);
|
||||
if (exists) {
|
||||
paths.push(repoPath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (paths.length === 0) throw error;
|
||||
}
|
||||
}
|
||||
if (includeWorkingTree) {
|
||||
return runWorkingTreeRangeDiff({ git, repoRoot }, baseRef, headRef, args, paths);
|
||||
}
|
||||
args.push(`${baseRef}...${headRef}`, '--', ...paths);
|
||||
const diff = await git.raw(args);
|
||||
return diff;
|
||||
}
|
||||
@@ -2702,6 +2734,9 @@ export function parseBranchCreationSource(reflogText) {
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
// Rebase records its destination as a commit, not a parent branch. The
|
||||
// creation ref is no longer evidence of the current base after restacking.
|
||||
if (lines.some((line) => /^rebase(?:\s|\()/.test(line))) return null;
|
||||
// Reflog lists newest entries first; the creation entry is the oldest one.
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const match = lines[index].match(BRANCH_CREATION_SOURCE_RE);
|
||||
@@ -2753,31 +2788,23 @@ export async function getBranchBase(directory, branch) {
|
||||
return { base: source };
|
||||
}
|
||||
|
||||
export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
export async function getRangeFiles(directory, { base, head, includeWorkingTree = false } = {}) {
|
||||
const { git, repoRoot } = await createRepositoryGitContext(directory);
|
||||
const baseRef = typeof base === 'string' ? base.trim() : '';
|
||||
const headRef = typeof head === 'string' ? head.trim() : '';
|
||||
if (!baseRef || !headRef) {
|
||||
throw new Error('base and head are required');
|
||||
}
|
||||
|
||||
let resolvedBase = baseRef;
|
||||
const originCandidate = `refs/remotes/origin/${baseRef}`;
|
||||
try {
|
||||
const verified = await git.raw(['rev-parse', '--verify', originCandidate]);
|
||||
if (verified && verified.trim()) {
|
||||
resolvedBase = `origin/${baseRef}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
|
||||
await assertRangeRefsResolve(git, [baseRef, headRef]);
|
||||
|
||||
// `-C` (copy detection among changed files only, so cheap) makes copies
|
||||
// surface as C entries instead of plain additions; rename detection is on
|
||||
// by default.
|
||||
const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]);
|
||||
const args = ['diff', '--name-status', '-z', '-C'];
|
||||
const raw = includeWorkingTree
|
||||
? await runWorkingTreeRangeDiff({ git, repoRoot }, baseRef, headRef, args)
|
||||
: await git.raw([...args, `${baseRef}...${headRef}`, '--']);
|
||||
// -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries
|
||||
// (`R100`, `C75`) the first path token is the ORIGINAL path and the second
|
||||
// is the DESTINATION — the diff (and the UI) must address the destination.
|
||||
@@ -2787,7 +2814,7 @@ export async function getRangeFiles(directory, { base, head } = {}) {
|
||||
const status = (tokens[index] || '').trim();
|
||||
if (!status) continue;
|
||||
const isRenameOrCopy = status.startsWith('R') || status.startsWith('C');
|
||||
const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim();
|
||||
const path = isRenameOrCopy ? (tokens[index + 2] || '') : (tokens[index + 1] || '');
|
||||
index += isRenameOrCopy ? 2 : 1;
|
||||
if (path) {
|
||||
files.push({ path, status: status.charAt(0) });
|
||||
@@ -2833,27 +2860,6 @@ const parseIsBinaryFromNumstat = (raw) => {
|
||||
return added === '-' || deleted === '-';
|
||||
};
|
||||
|
||||
const extractGitStatusPath = (status, pathPart) => {
|
||||
if ((status === 'R' || status === 'C') && pathPart.includes('\t')) {
|
||||
return pathPart.split('\t').pop() || pathPart;
|
||||
}
|
||||
return pathPart;
|
||||
};
|
||||
|
||||
const extractGitNumstatDestinationPath = (filePath) => {
|
||||
if (!filePath.includes(' => ')) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/);
|
||||
if (braceMatch) {
|
||||
const [, prefix, , destination, suffix] = braceMatch;
|
||||
return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
return filePath.split(' => ').pop()?.trim() || filePath;
|
||||
};
|
||||
|
||||
const looksBinaryBySniff = async (absolutePath) => {
|
||||
try {
|
||||
const handle = await fsp.open(absolutePath, 'r');
|
||||
@@ -4679,12 +4685,11 @@ export async function getLog(directory, options = {}) {
|
||||
};
|
||||
const resolvedFrom = await resolveBaseRefForLog(options.from, checkRef);
|
||||
|
||||
const baseLog = await git.log({
|
||||
maxCount,
|
||||
from: resolvedFrom,
|
||||
to: options.to,
|
||||
file: filePath
|
||||
});
|
||||
// simple-git's `to` alone means HEAD..to, which is empty for the current
|
||||
// branch. A single requested ref means its reachable history instead.
|
||||
const baseLog = options.to && !resolvedFrom
|
||||
? await git.log([`--max-count=${maxCount}`, options.to, ...(filePath ? ['--', filePath] : [])])
|
||||
: await git.log({ maxCount, from: resolvedFrom, to: options.to, file: filePath });
|
||||
|
||||
const logArgs = [
|
||||
'log',
|
||||
@@ -4928,85 +4933,61 @@ export async function canonicalizeWorktreeState(directory) {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveCommitHash(git, hash) {
|
||||
if (!/^[0-9a-f]{7,64}$/i.test(hash)) throw new Error('A commit hash is required');
|
||||
return (await git.raw(['rev-parse', '--verify', '--end-of-options', `${hash}^{commit}`])).trim();
|
||||
}
|
||||
|
||||
const commitShowArgs = (hash) => ['show', '--format=', '--root', '--diff-merges=first-parent', '--find-renames', hash];
|
||||
|
||||
export async function getCommitDiff(directory, { hash, path: filePath, previousPath, contextLines = 3 } = {}) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
const commit = await resolveCommitHash(git, hash);
|
||||
const paths = [filePath, previousPath].filter(Boolean).map((value) => `:(literal)${value}`);
|
||||
return git.raw([
|
||||
...commitShowArgs(commit), '--no-color', '--no-ext-diff', `-U${Math.max(0, contextLines)}`,
|
||||
'--', ...paths,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function getCommitFiles(directory, commitHash) {
|
||||
const { git } = await createRepositoryGitContext(directory);
|
||||
|
||||
try {
|
||||
|
||||
const numstatRaw = await git.raw([
|
||||
'show',
|
||||
'--numstat',
|
||||
'--format=',
|
||||
commitHash
|
||||
]);
|
||||
|
||||
const files = [];
|
||||
const lines = numstatRaw.trim().split('\n').filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t');
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
|
||||
const filePath = pathParts.join('\t');
|
||||
if (!filePath) continue;
|
||||
|
||||
const insertions = insertionsRaw === '-' ? 0 : parseInt(insertionsRaw, 10) || 0;
|
||||
const deletions = deletionsRaw === '-' ? 0 : parseInt(deletionsRaw, 10) || 0;
|
||||
const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
|
||||
|
||||
let changeType = 'M';
|
||||
let displayPath = filePath;
|
||||
|
||||
if (filePath.includes(' => ')) {
|
||||
changeType = 'R';
|
||||
|
||||
const match = filePath.match(/(?:\{[^}]*\s=>\s[^}]*\}|.*\s=>\s.*)/);
|
||||
if (match) {
|
||||
displayPath = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
files.push({
|
||||
path: displayPath,
|
||||
insertions,
|
||||
deletions,
|
||||
isBinary,
|
||||
changeType
|
||||
});
|
||||
const hash = await resolveCommitHash(git, commitHash);
|
||||
const [numstat, nameStatus] = await Promise.all([
|
||||
git.raw([...commitShowArgs(hash), '--numstat', '-z', '--']),
|
||||
git.raw([...commitShowArgs(hash), '--name-status', '-z', '--']),
|
||||
]);
|
||||
const stats = new Map();
|
||||
const tokens = numstat.split('\0');
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const match = /^(\d+|-)\t(\d+|-)\t([\s\S]*)$/.exec(tokens[index]);
|
||||
if (!match) continue;
|
||||
let destination = match[3];
|
||||
if (!destination) {
|
||||
destination = tokens[index + 2];
|
||||
index += 2;
|
||||
}
|
||||
|
||||
const nameStatusRaw = await git.raw([
|
||||
'show',
|
||||
'--name-status',
|
||||
'--format=',
|
||||
commitHash
|
||||
]).catch(() => '');
|
||||
|
||||
const statusMap = new Map();
|
||||
const statusLines = nameStatusRaw.trim().split('\n').filter(Boolean);
|
||||
for (const line of statusLines) {
|
||||
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
|
||||
if (match) {
|
||||
const [, status, pathPart] = match;
|
||||
statusMap.set(extractGitStatusPath(status, pathPart), status);
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const basePath = extractGitNumstatDestinationPath(file.path);
|
||||
|
||||
const status = statusMap.get(basePath) || statusMap.get(file.path);
|
||||
if (status) {
|
||||
file.changeType = status;
|
||||
}
|
||||
}
|
||||
|
||||
return { files };
|
||||
} catch (error) {
|
||||
console.error('Failed to get commit files:', error);
|
||||
throw error;
|
||||
stats.set(destination, {
|
||||
insertions: Number.parseInt(match[1], 10) || 0,
|
||||
deletions: Number.parseInt(match[2], 10) || 0,
|
||||
isBinary: match[1] === '-',
|
||||
});
|
||||
}
|
||||
const files = [];
|
||||
const names = nameStatus.split('\0');
|
||||
for (let index = 0; index < names.length; index += 1) {
|
||||
const changeType = names[index].charAt(0);
|
||||
if (!changeType) continue;
|
||||
const renamed = changeType === 'R' || changeType === 'C';
|
||||
const previousPath = renamed ? names[++index] : undefined;
|
||||
const filePath = names[++index];
|
||||
const fileStats = stats.get(filePath);
|
||||
if (!filePath || !fileStats) throw new Error('Incomplete commit file statistics');
|
||||
const entry = { path: filePath, ...fileStats, changeType };
|
||||
if (previousPath) entry.previousPath = previousPath;
|
||||
files.push(entry);
|
||||
}
|
||||
return { files };
|
||||
}
|
||||
|
||||
export async function renameBranch(directory, oldName, newName) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import simpleGit from 'simple-git';
|
||||
import { loadSourceSections, parseSource, sourceKey } from '../walkthrough/sources.js';
|
||||
import { registerGitRoutes } from './routes.js';
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
@@ -14,6 +16,10 @@ import {
|
||||
getBranches,
|
||||
getUnpushedBranchCounts,
|
||||
getRangeDiff,
|
||||
getBranchBase,
|
||||
getCommitDiff,
|
||||
getCommitFiles,
|
||||
getLog,
|
||||
getStatus,
|
||||
getWorktrees,
|
||||
isGitRepository,
|
||||
@@ -1704,18 +1710,249 @@ describe.runIf(canRunGit())('getUnpushedBranchCounts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('commit comparisons', () => {
|
||||
it('shows only the selected commit and gives walkthrough the identical patch', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'selected version\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'selected']);
|
||||
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'later version\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'later']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), 'uncommitted version\n');
|
||||
const patch = await getCommitDiff(repository, { hash, path: 'README.md' });
|
||||
expect(patch).toContain('+selected version');
|
||||
expect(patch).not.toContain('later version');
|
||||
expect(patch).not.toContain('uncommitted version');
|
||||
expect((await getCommitFiles(repository, hash)).files).toEqual([
|
||||
{ path: 'README.md', insertions: 1, deletions: 1, isBinary: false, changeType: 'M' },
|
||||
]);
|
||||
const source = parseSource({ kind: 'commit', hash });
|
||||
expect(sourceKey(source)).toBe(`commit:${hash}`);
|
||||
expect((await loadSourceSections(repository, source)).sections).toEqual([{ scope: 'commit', patch }]);
|
||||
const routes = new Map();
|
||||
registerGitRoutes({
|
||||
get: (url, handler) => routes.set(url, handler), post() {}, put() {}, delete() {},
|
||||
});
|
||||
let response;
|
||||
await routes.get('/api/git/commit-diff')(
|
||||
{ query: { directory: repository, hash, path: 'README.md' } },
|
||||
{ json: (body) => { response = body; }, status: (code) => { throw new Error(`Unexpected status ${code}`); } },
|
||||
);
|
||||
expect(response).toEqual({ diff: patch });
|
||||
});
|
||||
|
||||
it('handles root and empty commits and rejects invalid hashes', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
const root = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
expect(await getCommitDiff(repository, { hash: root })).toContain('+# Test');
|
||||
expect((await getCommitFiles(repository, root)).files[0].changeType).toBe('A');
|
||||
runGit(repository, ['commit', '--allow-empty', '-m', 'empty']);
|
||||
const empty = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
expect(await getCommitDiff(repository, { hash: empty })).toBe('');
|
||||
expect(await getCommitFiles(repository, empty)).toEqual({ files: [] });
|
||||
expect(() => parseSource({ kind: 'commit', hash: 'HEAD' })).toThrow();
|
||||
expect(() => parseSource({ kind: 'commit', hash: [root] })).toThrow();
|
||||
await expect(getCommitDiff(repository, { hash: 'HEAD' })).rejects.toThrow();
|
||||
await expect(getCommitFiles(repository, '0'.repeat(40))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('keeps rename paths and original contents together, including whitespace in names', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
const destination = ' new\nname.md';
|
||||
runGit(repository, ['mv', 'README.md', destination]);
|
||||
runGit(repository, ['commit', '-m', 'rename']);
|
||||
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
const { files } = await getCommitFiles(repository, hash);
|
||||
expect(files).toEqual([{ path: destination, previousPath: 'README.md', changeType: 'R', insertions: 0, deletions: 0, isBinary: false }]);
|
||||
const patch = await getCommitDiff(repository, { hash, path: destination, previousPath: files[0].previousPath });
|
||||
expect(patch).toContain('rename from README.md');
|
||||
expect(patch).toContain('similarity index 100%');
|
||||
});
|
||||
|
||||
it('compares a merge commit against its first parent', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['checkout', '-b', 'side']);
|
||||
fs.writeFileSync(path.join(repository, 'side.txt'), 'side\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'side']);
|
||||
runGit(repository, ['checkout', 'next']);
|
||||
fs.writeFileSync(path.join(repository, 'main.txt'), 'main\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'main']);
|
||||
runGit(repository, ['merge', '--no-ff', 'side', '-m', 'merge']);
|
||||
const hash = runGit(repository, ['rev-parse', 'HEAD']).trim();
|
||||
expect((await getCommitFiles(repository, hash)).files.map((file) => file.path)).toEqual(['side.txt']);
|
||||
const patch = await getCommitDiff(repository, { hash });
|
||||
expect(patch).toContain('+side');
|
||||
expect(patch).not.toContain('main.txt');
|
||||
});
|
||||
|
||||
it('limits current-branch history to 50 commits without including another branch', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['checkout', '-b', 'other']);
|
||||
runGit(repository, ['commit', '--allow-empty', '-m', 'other branch only']);
|
||||
runGit(repository, ['checkout', 'next']);
|
||||
for (let index = 0; index < 51; index += 1) runGit(repository, ['commit', '--allow-empty', '-m', `current ${index}`]);
|
||||
const history = await getLog(repository, { maxCount: 50, to: 'refs/heads/next' });
|
||||
expect(history.all).toHaveLength(50);
|
||||
expect(history.all[0].message).toBe('current 50');
|
||||
expect(history.all.some((commit) => commit.message === 'other branch only')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
it('resolves a base that exists only on a remote other than origin', async () => {
|
||||
it('loads a committed deletion that no longer exists in HEAD or the working tree', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['rm', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'delete file']);
|
||||
const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', path: 'README.md', includeWorkingTree: true });
|
||||
expect(diff).toContain('deleted file mode');
|
||||
expect(diff).toContain('-# Test');
|
||||
});
|
||||
|
||||
it('carries the working-tree option through the actual HTTP route handlers', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'local.txt'), 'current local work\n');
|
||||
const routes = new Map();
|
||||
registerGitRoutes({
|
||||
get: (url, handler) => routes.set(url, handler),
|
||||
post() {},
|
||||
put() {},
|
||||
delete() {},
|
||||
});
|
||||
const query = { directory: repository, base: 'origin/react', head: 'next', includeWorkingTree: 'true' };
|
||||
for (const endpoint of ['range-diff', 'range-files']) {
|
||||
let status = 200;
|
||||
let body;
|
||||
const response = {
|
||||
status(value) { status = value; return this; },
|
||||
json(value) { body = value; },
|
||||
};
|
||||
await routes.get(`/api/git/${endpoint}`)({ query }, response);
|
||||
expect(status).toBe(200);
|
||||
if (endpoint === 'range-diff') expect(body.diff).toContain('+current local work');
|
||||
else expect(body.files).toEqual([{ path: 'local.txt', status: 'A' }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('asks for a new base after restacking and compares against the selected parent', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['checkout', '-b', 'child', 'origin/react']);
|
||||
fs.writeFileSync(path.join(repository, 'child.txt'), 'child\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'child']);
|
||||
expect(await getBranchBase(repository, 'child')).toEqual({ base: 'origin/react' });
|
||||
runGit(repository, ['checkout', '-b', 'parent', 'origin/react']);
|
||||
fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'parent']);
|
||||
runGit(repository, ['checkout', 'child']);
|
||||
runGit(repository, ['rebase', 'parent']);
|
||||
expect(await getBranchBase(repository, 'child')).toEqual({ base: null });
|
||||
fs.writeFileSync(path.join(repository, 'child.txt'), 'current child\n');
|
||||
const options = { base: 'refs/heads/parent', head: 'child', includeWorkingTree: true };
|
||||
expect(await getRangeFiles(repository, options)).toEqual([{ path: 'child.txt', status: 'A' }]);
|
||||
const diff = await getRangeDiff(repository, options);
|
||||
expect(diff).toContain('+current child');
|
||||
expect(diff).not.toContain('parent.txt');
|
||||
});
|
||||
|
||||
it('combines committed, staged, unstaged and untracked work without changing the real index', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Committed\n');
|
||||
runGit(repository, ['add', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'branch change']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Staged\n');
|
||||
fs.writeFileSync(path.join(repository, 'staged.txt'), 'staged only\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Current\n');
|
||||
fs.writeFileSync(path.join(repository, 'untracked.txt'), 'new local file\n');
|
||||
fs.writeFileSync(path.join(repository, ' leading space.txt'), 'space path\n');
|
||||
const indexBefore = fs.readFileSync(path.join(repository, '.git/index'));
|
||||
const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
|
||||
|
||||
const diff = await getRangeDiff(repository, options);
|
||||
expect(diff).toContain('-# Test');
|
||||
expect(diff).toContain('+# Current');
|
||||
expect(diff).not.toContain('+# Staged');
|
||||
expect(diff).not.toContain('+# Committed');
|
||||
expect(diff).toContain('+new local file');
|
||||
expect(diff).toContain('+staged only');
|
||||
expect(await getRangeFiles(repository, options)).toEqual(expect.arrayContaining([
|
||||
{ path: 'README.md', status: 'M' },
|
||||
{ path: 'staged.txt', status: 'A' },
|
||||
{ path: 'untracked.txt', status: 'A' },
|
||||
{ path: ' leading space.txt', status: 'A' },
|
||||
]));
|
||||
const { sections } = await loadSourceSections(repository, { kind: 'branch', baseRef: options.base, headRef: options.head });
|
||||
expect(sections).toEqual([{ scope: 'branch', patch: diff }]);
|
||||
expect(fs.readFileSync(path.join(repository, '.git/index'))).toEqual(indexBefore);
|
||||
|
||||
const committed = await getRangeDiff(repository, { base: options.base, head: options.head });
|
||||
expect(committed).toContain('+# Committed');
|
||||
expect(committed).not.toContain('+new local file');
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Latest\n');
|
||||
expect(await getRangeDiff(repository, { ...options, path: 'README.md' })).toContain('+# Latest');
|
||||
});
|
||||
|
||||
it('reports the final file after a staged deletion is recreated, and omits undone branch changes', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['rm', 'README.md']);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Recreated\n');
|
||||
const options = { base: 'origin/react', head: 'next', includeWorkingTree: true };
|
||||
expect(await getRangeFiles(repository, options)).toEqual([{ path: 'README.md', status: 'M' }]);
|
||||
const diff = await getRangeDiff(repository, options);
|
||||
expect(diff).toContain('-# Test');
|
||||
expect(diff).toContain('+# Recreated');
|
||||
expect(diff.match(/diff --git/g)).toHaveLength(1);
|
||||
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n');
|
||||
expect(await getRangeFiles(repository, options)).toEqual([]);
|
||||
expect(await getRangeDiff(repository, options)).toBe('');
|
||||
});
|
||||
|
||||
it('keeps local and remote bases distinct and rejects a different checked-out branch', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
runGit(repository, ['branch', 'react']);
|
||||
fs.writeFileSync(path.join(repository, 'parent.txt'), 'parent work\n');
|
||||
runGit(repository, ['add', '.']);
|
||||
runGit(repository, ['commit', '-m', 'parent work']);
|
||||
runGit(repository, ['branch', '-f', 'react', 'HEAD']);
|
||||
fs.writeFileSync(path.join(repository, 'child.txt'), 'child work\n');
|
||||
const options = { head: 'next', includeWorkingTree: true };
|
||||
const local = await getRangeDiff(repository, { ...options, base: 'react' });
|
||||
const remote = await getRangeDiff(repository, { ...options, base: 'origin/react' });
|
||||
expect(local).not.toContain('parent.txt');
|
||||
expect(remote).toContain('parent.txt');
|
||||
expect(local).toContain('child.txt');
|
||||
expect(await getRangeFiles(repository, { ...options, base: 'react' })).toEqual([{ path: 'child.txt', status: 'A' }]);
|
||||
runGit(repository, ['checkout', 'react']);
|
||||
await expect(getRangeDiff(repository, { ...options, base: 'origin/react' })).rejects.toThrow(/checked-out branch/);
|
||||
});
|
||||
|
||||
it('includes untracked symlinks as links without reading their targets', async () => {
|
||||
const { repository } = createRepositoryWithRemote();
|
||||
const outside = path.join(createTempDir(), 'outside.txt');
|
||||
fs.writeFileSync(outside, 'must not be in a diff\n');
|
||||
fs.symlinkSync(outside, path.join(repository, 'link.txt'));
|
||||
const diff = await getRangeDiff(repository, { base: 'origin/react', head: 'next', includeWorkingTree: true });
|
||||
expect(diff).toContain('new file mode 120000');
|
||||
expect(diff).toContain(outside);
|
||||
expect(diff).not.toContain('must not be in a diff');
|
||||
});
|
||||
|
||||
it('uses an explicitly selected base on a remote other than origin', async () => {
|
||||
const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' });
|
||||
// Only refs/remotes/upstream/react carries the base — git cannot resolve the
|
||||
// bare name, so an unqualified `react...next` fails with "ambiguous argument".
|
||||
// The selected remote ref must work without a local branch of that name.
|
||||
fs.writeFileSync(path.join(repository, 'feature.txt'), 'work\n');
|
||||
runGit(repository, ['add', 'feature.txt']);
|
||||
runGit(repository, ['commit', '-m', 'feature']);
|
||||
|
||||
const diff = await getRangeDiff(repository, { base: 'react', head: 'next' });
|
||||
const diff = await getRangeDiff(repository, { base: 'upstream/react', head: 'next' });
|
||||
|
||||
expect(diff).toContain('feature.txt');
|
||||
await expect(getRangeDiff(repository, { base: 'react', head: 'next' })).rejects.toThrow(/is not available locally/);
|
||||
});
|
||||
|
||||
it('names an unfetched remote-only ref instead of failing with git\'s ambiguous argument (#2735)', async () => {
|
||||
@@ -1728,6 +1965,9 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
|
||||
});
|
||||
|
||||
describe('parseBranchCreationSource', () => {
|
||||
it('does not reuse the creation base after a rebase', () => {
|
||||
expect(parseBranchCreationSource('rebase (finish): refs/heads/feature onto abc123\nbranch: Created from main')).toBeNull();
|
||||
});
|
||||
it('returns the source ref from the oldest creation entry', () => {
|
||||
// Reflog lists newest entries first; creation is the last line.
|
||||
const reflog = [
|
||||
@@ -1774,7 +2014,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
runGit(repository, ['add', 'added.txt', 'README.md']);
|
||||
runGit(repository, ['commit', '-m', 'changes']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
||||
|
||||
expect(files).toEqual(expect.arrayContaining([
|
||||
{ path: 'added.txt', status: 'A' },
|
||||
@@ -1796,7 +2036,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'rename']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
||||
|
||||
const renameEntry = files.find((file) => file.status === 'R');
|
||||
expect(renameEntry).toBeDefined();
|
||||
@@ -1818,7 +2058,7 @@ describe.runIf(canRunGit())('getRangeFiles', () => {
|
||||
runGit(repository, ['add', '-A']);
|
||||
runGit(repository, ['commit', '-m', 'copy']);
|
||||
|
||||
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
|
||||
const files = await getRangeFiles(repository, { base: 'origin/react', head: 'next' });
|
||||
|
||||
const copyEntry = files.find((file) => file.status === 'C');
|
||||
expect(copyEntry).toBeDefined();
|
||||
|
||||
@@ -52,20 +52,39 @@ written against staged code never silently re-anchors onto an unstaged edit.
|
||||
| Kind | Sections | Notes |
|
||||
|---|---|---|
|
||||
| `working-tree` (`all` \| `staged` \| `working`) | `staged`, `working` | Untracked files are fetched individually because `git diff` omits them |
|
||||
| `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded |
|
||||
| `pr` | `pr:<number>` | GitHub returns the merge-base diff, matching the branch semantics |
|
||||
| `branch` | `branch` | `getRangeDiff` with `includeWorkingTree: true` compares the selected merge base with current files, including committed and local work in one net diff |
|
||||
| `commit` | `commit` | `getCommitDiff` compares the full selected commit hash with its first parent; root commits compare with an empty tree |
|
||||
| `pr` | `pr:<number>` | GitHub's committed pull-request diff, without local working-tree changes |
|
||||
|
||||
For the current-branch source, the UI takes the base from the default branch of
|
||||
the current branch's tracking remote (`defaultBranches` in the branches
|
||||
response), and only then falls back to the conventional names. It does not offer
|
||||
the source at all when the chosen base exists neither locally nor on a remote —
|
||||
a repository whose default is neither `main`, `master` nor `develop` used to be
|
||||
handed `main...<head>`, which git rejects outright.
|
||||
Changes and walkthrough resolve the current branch's base through
|
||||
`packages/ui/src/hooks/useBranchComparisonBase.ts`. An explicit choice in Changes
|
||||
outranks reflog detection. Both toolbars use
|
||||
`packages/ui/src/components/views/git/BranchComparisonSelector.tsx` to select or
|
||||
change the base directly. Walkthrough allows selecting Branch before a base is
|
||||
known and waits for a valid choice before loading or generating. Opening
|
||||
walkthrough from Changes carries the selected base and head; later selections
|
||||
in either toolbar update both comparisons.
|
||||
|
||||
A base that exists only on a remote still works: `getRangeDiff` prefers
|
||||
`origin/<base>` when it exists, and otherwise resolves the base through whichever
|
||||
remote carries it, because a bare branch name git cannot find in `refs/heads`
|
||||
fails the same way.
|
||||
Commit mode uses the shared `CommitComparisonSelector` in both toolbars. It
|
||||
lists the latest 50 commits reachable from the checked-out branch, with subject,
|
||||
author, date, and short hash. Opening the picker refreshes that list; selecting a
|
||||
commit changes the comparison, not the checkout. Changes hands the selected full
|
||||
hash to walkthrough. The server accepts full object IDs for commit sources and
|
||||
keys their cache entries and generation jobs as `commit:<hash>`, so reviews of
|
||||
different commits cannot overwrite each other. Existing source keys keep their
|
||||
format. Commit reads have no working-tree freshness dependency, and selecting a
|
||||
commit never starts model generation.
|
||||
|
||||
The Git module owns exact-ref and working-tree comparison semantics. Local and
|
||||
remote bases remain distinct, and a checkout during a branch review requires
|
||||
the source to be resolved for the new branch rather than including another
|
||||
branch's local files.
|
||||
|
||||
Successful status refreshes invalidate the visible branch comparison even when
|
||||
file names and insertion/deletion counts stay the same. Walkthrough refreshes
|
||||
its current hunk index while visible; regeneration remains user-initiated. The
|
||||
content-addressed cache continues to reuse an old review only when its hunks
|
||||
match, and otherwise reports stale anchors and uncovered current hunks.
|
||||
|
||||
The panel offers the current branch's pull request on its own: it registers with
|
||||
the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting
|
||||
|
||||
@@ -86,7 +86,9 @@ export function buildPrompt({ digest, fileCount, hunkCount, source, previousWalk
|
||||
? `Uncommitted local changes (${source.scope === 'all' ? 'staged and unstaged' : source.scope}).`
|
||||
: source.kind === 'branch'
|
||||
? `All work on branch "${source.headRef}" that is not in "${source.baseRef}". Changes merged in from ${source.baseRef} are already excluded.`
|
||||
: `Pull request #${source.number}.`;
|
||||
: source.kind === 'commit'
|
||||
? `Only the changes introduced by commit ${source.hash}, relative to its first parent (or the empty tree for a root commit).`
|
||||
: `Pull request #${source.number}.`;
|
||||
|
||||
const prompt = `Reviewing: ${sourceLine}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDiff, getRangeDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
|
||||
import { getDiff, getRangeDiff, getCommitDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js';
|
||||
|
||||
// A walkthrough source resolves to one or more diff *sections*. A section is a
|
||||
// patch plus the scope its hunk ids live in; keeping staged and working-tree
|
||||
@@ -48,6 +48,18 @@ export function parseSource(raw) {
|
||||
return { kind: 'pr', number };
|
||||
}
|
||||
|
||||
if (raw.kind === 'commit') {
|
||||
// Sources are content-addressed: accept a full object id, never a moving ref.
|
||||
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(raw.hash)) {
|
||||
throw new WalkthroughSourceError('commit sources require a full commit hash');
|
||||
}
|
||||
try {
|
||||
return { kind: 'commit', hash: raw.hash.toLowerCase() };
|
||||
} catch {
|
||||
throw new WalkthroughSourceError('commit sources require a full commit hash');
|
||||
}
|
||||
}
|
||||
|
||||
throw new WalkthroughSourceError(`Unknown source kind "${String(raw.kind)}"`);
|
||||
}
|
||||
|
||||
@@ -58,6 +70,7 @@ export function parseSource(raw) {
|
||||
export function sourceKey(source) {
|
||||
if (source.kind === 'working-tree') return `working-tree:${source.scope}`;
|
||||
if (source.kind === 'branch') return `branch:${source.baseRef}...${source.headRef}`;
|
||||
if (source.kind === 'commit') return `commit:${source.hash}`;
|
||||
return `pr:${source.number}`;
|
||||
}
|
||||
|
||||
@@ -97,13 +110,21 @@ export async function loadSourceSections(directory, source, { getPullRequestDiff
|
||||
}
|
||||
|
||||
if (source.kind === 'branch') {
|
||||
const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef });
|
||||
const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef, includeWorkingTree: true });
|
||||
return {
|
||||
sections: patch && patch.trim() ? [{ scope: 'branch', patch }] : [],
|
||||
meta: { baseRef: source.baseRef, headRef: source.headRef },
|
||||
};
|
||||
}
|
||||
|
||||
if (source.kind === 'commit') {
|
||||
const patch = await getCommitDiff(directory, { hash: source.hash });
|
||||
return {
|
||||
sections: patch.trim() ? [{ scope: 'commit', patch }] : [],
|
||||
meta: { hash: source.hash },
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof getPullRequestDiff !== 'function') {
|
||||
throw new WalkthroughSourceError('Pull request diffs are unavailable', 500);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const createWebGitAPI = (): GitAPI => ({
|
||||
return gitApiHttp.getGitLog(directory, options);
|
||||
},
|
||||
getCommitFiles: gitApiHttp.getCommitFiles,
|
||||
getGitCommitDiff: gitApiHttp.getGitCommitDiff,
|
||||
getCurrentGitIdentity: gitApiHttp.getCurrentGitIdentity,
|
||||
hasLocalIdentity: gitApiHttp.hasLocalIdentity,
|
||||
setGitIdentity: gitApiHttp.setGitIdentity,
|
||||
|
||||
Reference in New Issue
Block a user