Refine changes panel controls

This commit is contained in:
Bohdan Triapitsyn
2026-06-14 00:51:12 +03:00
parent 8b318d2776
commit fa128afdb5
14 changed files with 277 additions and 36 deletions
@@ -199,7 +199,7 @@ const getTabLabel = (
}
if (tab.mode === 'diff') {
return tab.stagedDiff ? t('contextPanel.mode.stagedDiff') : t('contextPanel.mode.workingDiff');
return t('contextPanel.mode.diff');
}
return getModeLabel(tab.mode, t);
@@ -1997,6 +1997,7 @@ export const ContextPanel: React.FC = () => {
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const closeContextPanelTab = useUIStore((state) => state.closeContextPanelTab);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded);
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
const setActiveContextPanelTab = useUIStore((state) => state.setActiveContextPanelTab);
@@ -2178,6 +2179,18 @@ export const ContextPanel: React.FC = () => {
const activeChatTabID = activeTab?.mode === 'chat' ? activeTab.id : null;
const handleDiffScopeChange = React.useCallback((nextScope: 'working' | 'staged') => {
if (!directoryKey || activeTab?.mode !== 'diff') {
return;
}
openContextPanelTab(directoryKey, {
mode: 'diff',
targetPath: activeTab.targetPath,
stagedDiff: nextScope === 'staged',
});
}, [activeTab, directoryKey, openContextPanelTab]);
const postThemeSyncToEmbeddedChat = React.useCallback(() => {
if (typeof window === 'undefined') {
return;
@@ -2286,6 +2299,7 @@ export const ContextPanel: React.FC = () => {
pinSelectedFileHeaderToTopOnNavigate
showOpenInEditorAction
diffScope={activeTab.stagedDiff ? 'staged' : 'working'}
onDiffScopeChange={handleDiffScopeChange}
targetFilePath={activeTab.targetPath}
flushContent
/>
@@ -717,6 +717,7 @@ export const Header: React.FC<HeaderProps> = ({
const openContextOverview = useUIStore((state) => state.openContextOverview);
const openContextPlan = useUIStore((state) => state.openContextPlan);
const openContextBrowser = useUIStore((state) => state.openContextBrowser);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
const contextPanelByDirectory = useUIStore((state) => state.contextPanelByDirectory);
const activeMainTab = useUIStore((state) => state.activeMainTab);
@@ -1495,6 +1496,21 @@ export const Header: React.FC<HeaderProps> = ({
openContextPlan(directory);
}, [closeContextPanel, contextPanelByDirectory, openContextPlan, openDirectory]);
const handleOpenContextChanges = React.useCallback(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return;
}
const panelState = contextPanelByDirectory[directory];
if (getActiveContextMode(panelState) === 'diff') {
closeContextPanel(directory);
return;
}
openContextPanelTab(directory, { mode: 'diff', stagedDiff: false });
}, [closeContextPanel, contextPanelByDirectory, openContextPanelTab, openDirectory]);
const handleOpenContextBrowser = React.useCallback(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
@@ -1519,6 +1535,15 @@ export const Header: React.FC<HeaderProps> = ({
return getActiveContextMode(panelState) === 'plan';
}, [contextPanelByDirectory, openDirectory]);
const isContextChangesActive = React.useMemo(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
return false;
}
const panelState = contextPanelByDirectory[directory];
return getActiveContextMode(panelState) === 'diff';
}, [contextPanelByDirectory, openDirectory]);
const isContextBrowserActive = React.useMemo(() => {
const directory = normalize(openDirectory || '');
if (!directory) {
@@ -1992,6 +2017,28 @@ export const Header: React.FC<HeaderProps> = ({
const desktopSidebarActions = (
<>
{!isVSCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t('header.actions.toggleChangesPanelAria')}
aria-pressed={isContextChangesActive}
onClick={handleOpenContextChanges}
className={desktopHeaderIconButtonClass}
>
<span className="relative h-5 w-5 overflow-hidden rounded-[2px]">
<span className="absolute left-[4px] top-[4px] h-3 w-[5px] bg-[var(--status-error)]/25" />
<span className="absolute right-[4px] top-[4px] h-3 w-[5px] bg-[var(--status-success)]/25" />
<Icon name="layout-column" className="absolute inset-0 h-5 w-5" />
</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('header.actions.toggleChangesPanel')}</p>
</TooltipContent>
</Tooltip>
) : null}
{showPlanTab && (
<Tooltip>
<TooltipTrigger asChild>
+122 -25
View File
@@ -176,12 +176,22 @@ const createTextDiffDataFromPatch = (filePath: string, patch: string): DiffData
};
};
const formatDiffTotals = (insertions?: number, deletions?: number) => {
const formatDiffTotals = (
insertions?: number,
deletions?: number,
options?: { shrink?: boolean; className?: string },
) => {
const added = insertions ?? 0;
const removed = deletions ?? 0;
if (!added && !removed) return null;
return (
<span className="typography-meta flex flex-shrink-0 items-center gap-1 text-xs whitespace-nowrap">
<span
className={cn(
'typography-meta flex items-center gap-1 text-xs whitespace-nowrap',
options?.shrink ? 'min-w-0 overflow-hidden' : 'flex-shrink-0',
options?.className,
)}
>
{added ? <span style={{ color: 'var(--status-success)' }}>+{added}</span> : null}
{removed ? <span style={{ color: 'var(--status-error)' }}>-{removed}</span> : null}
</span>
@@ -232,6 +242,7 @@ interface FileSelectorProps {
selectedFile: string | null;
selectedFileEntry: FileEntry | null;
onSelectFile: (path: string) => void;
className?: string;
}
const FileSelector = React.memo<FileSelectorProps>(({
@@ -239,6 +250,7 @@ const FileSelector = React.memo<FileSelectorProps>(({
selectedFile,
selectedFileEntry,
onSelectFile,
className,
}) => {
const { t } = useI18n();
@@ -247,24 +259,27 @@ const FileSelector = React.memo<FileSelectorProps>(({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex h-7 min-w-0 max-w-[min(52rem,60vw)] items-center gap-2 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground outline-none hover:bg-interactive-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
<button className={cn(
'diff-toolbar__file-trigger flex h-7 min-w-[3.75rem] max-w-full items-center gap-2 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground outline-none hover:bg-interactive-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring',
className,
)}>
{selectedFileEntry ? (
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="diff-toolbar__file-trigger-content flex min-w-0 flex-1 items-center gap-2">
<FileTypeIcon filePath={selectedFileEntry.path} className="h-3.5 w-3.5 flex-shrink-0" />
<DiffFilePathLabel path={selectedFileEntry.path} className="flex-1" />
{formatDiffTotals(selectedFileEntry.insertions, selectedFileEntry.deletions)}
<DiffFilePathLabel path={selectedFileEntry.path} className="diff-toolbar__file-label min-w-0 flex-1" />
{formatDiffTotals(selectedFileEntry.insertions, selectedFileEntry.deletions, { shrink: true, className: 'diff-toolbar__file-stats' })}
</div>
) : (
<span className="text-muted-foreground">{t('diffView.selector.selectFile')}</span>
<span className="min-w-0 truncate text-muted-foreground">{t('diffView.selector.selectFile')}</span>
)}
<Icon name="arrow-down-s" className="size-4 opacity-50" />
<Icon name="arrow-down-s" className="size-4 flex-shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[70vh] w-[max(var(--anchor-width),min(28rem,calc(100vw-2rem)))] max-w-[calc(100vw-2rem)] overflow-y-auto">
<DropdownMenuContent className="max-h-[70vh] w-[min(max(var(--anchor-width),18rem),36rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] overflow-y-auto">
<DropdownMenuRadioGroup value={selectedFile ?? ''} onValueChange={onSelectFile}>
{changedFiles.map((file) => (
<DropdownMenuRadioItem key={file.path} value={file.path} className="min-w-0 items-center">
<div className="flex w-full min-w-0 items-center gap-3">
<div className="flex w-full min-w-0 items-center gap-2.5">
<FileTypeIcon filePath={file.path} className="h-3.5 w-3.5 flex-shrink-0" />
<DiffFilePathLabel path={file.path} className="flex-1" />
{formatDiffTotals(file.insertions, file.deletions)}
@@ -277,6 +292,64 @@ const FileSelector = React.memo<FileSelectorProps>(({
);
});
interface ChangeScopeSelectorProps {
scope: Extract<DiffScope, 'working' | 'staged'>;
workingCount: number;
stagedCount: number;
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged'>) => void;
}
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
scope,
workingCount,
stagedCount,
onScopeChange,
}) => {
const { t } = useI18n();
const currentCount = scope === 'staged' ? stagedCount : workingCount;
const currentLabel = scope === 'staged' ? t('diffView.scope.staged') : t('diffView.scope.changed');
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 flex-shrink-0 items-center gap-1.5 rounded-md px-2 typography-ui-label font-semibold text-foreground outline-none hover:bg-interactive-hover focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t('diffView.scope.selectorAria')}
>
<span className="whitespace-nowrap">
{currentLabel}<span className="diff-toolbar__scope-count">: {currentCount}</span>
</span>
<Icon name="arrow-down-s" className="size-4 flex-shrink-0 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
<DropdownMenuRadioGroup
value={scope}
onValueChange={(value) => {
if (value === 'working' || value === 'staged') {
onScopeChange?.(value);
}
}}
>
<DropdownMenuRadioItem value="working">
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>{t('diffView.scope.changed')}</span>
<span className="typography-meta text-muted-foreground">{workingCount}</span>
</span>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="staged">
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span>{t('diffView.scope.staged')}</span>
<span className="typography-meta text-muted-foreground">{stagedCount}</span>
</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
});
interface FileListProps {
changedFiles: FileEntry[];
selectedFile: string | null;
@@ -766,6 +839,7 @@ interface DiffViewProps {
pinSelectedFileHeaderToTopOnNavigate?: boolean;
showOpenInEditorAction?: boolean;
diffScope?: DiffScope;
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged'>) => void;
targetFilePath?: string | null;
/** Render diff content flush with the container edges (no outer padding). */
flushContent?: boolean;
@@ -778,6 +852,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
pinSelectedFileHeaderToTopOnNavigate = false,
showOpenInEditorAction = false,
diffScope = 'all',
onDiffScopeChange,
targetFilePath = null,
flushContent = false,
}) => {
@@ -868,6 +943,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
.sort((a, b) => a.path.localeCompare(b.path));
}, [diffScope, status]);
const workingFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isWorkingStatusFile).length;
}, [status]);
const stagedFileCount = React.useMemo(() => {
if (!status?.files) return 0;
return status.files.filter(isStagedStatusFile).length;
}, [status]);
const selectedFileEntry = React.useMemo(() => {
if (!selectedFile) return null;
return changedFiles.find((file) => file.path === selectedFile) ?? null;
@@ -1391,18 +1476,26 @@ export const DiffView: React.FC<DiffViewProps> = ({
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
<div className="flex items-center gap-3 px-3 py-2 bg-background">
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
{!isMobile && (
<div className="flex items-center gap-1 rounded-md px-2 py-1 text-muted-foreground shrink-0">
<Icon name="git-commit" className="h-4 w-4" />
<span className="typography-ui-label font-semibold text-foreground">
{isLoadingStatus && !status
? t('diffView.state.loadingChanges')
: (changedFiles.length === 1
? t('diffView.summary.changedFilesSingle', { count: changedFiles.length })
: t('diffView.summary.changedFilesPlural', { count: changedFiles.length }))}
</span>
</div>
diffScope === 'working' || diffScope === 'staged' ? (
<ChangeScopeSelector
scope={diffScope}
workingCount={workingFileCount}
stagedCount={stagedFileCount}
onScopeChange={onDiffScopeChange}
/>
) : (
<div className="flex items-center gap-1 rounded-md px-2 py-1 text-muted-foreground shrink-0">
<span className="typography-ui-label font-semibold text-foreground">
{isLoadingStatus && !status
? t('diffView.state.loadingChanges')
: (changedFiles.length === 1
? t('diffView.summary.changedFilesSingle', { count: changedFiles.length })
: t('diffView.summary.changedFilesPlural', { count: changedFiles.length }))}
</span>
</div>
)
)}
{showFileSelector && (
<FileSelector
@@ -1410,22 +1503,26 @@ export const DiffView: React.FC<DiffViewProps> = ({
selectedFile={selectedFile}
selectedFileEntry={selectedFileEntry}
onSelectFile={handleSelectFileAndScroll}
className="w-fit min-w-0"
/>
)}
<div className="flex-1" />
{!showFileSelector ? <div className="min-w-0 flex-1" /> : null}
{changedFiles.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleExpandOrCollapseAll}
className="h-7 gap-1.5 px-2 text-muted-foreground hover:text-foreground"
className={cn(
'diff-toolbar__expand-button h-7 flex-shrink-0 gap-1 px-1.5 text-muted-foreground hover:text-foreground',
showFileSelector && 'ml-auto',
)}
title={expandedFiles.size > 0 ? t('diffView.actions.collapseAll') : t('diffView.actions.expandAll')}
>
<Icon
name={expandedFiles.size > 0 ? 'arrow-up-s' : 'arrow-down-s'}
name="expand-up-down"
className="size-4"
/>
<span className="typography-ui-label hidden sm:inline">
<span className="diff-toolbar__expand-label typography-ui-label">
{expandedFiles.size > 0 ? t('diffView.actions.collapseAll') : t('diffView.actions.expandAll')}
</span>
</Button>