feat(ui): redesign workspace shell with context panel, tabbed sidebars, and faster diff UX (#433)
* feat: tabbed right sidebar, context panel, floating diff comments * fix: auto-close left sidebar when context panel opens - Increase default context panel width from 520 to 600 pixels - Increase sidebar minimum width from 200 to 300 pixels - Replace collapsible component with custom button in diff view * refactoring: rework sidebars, tabs, and file tree layout - Rewrite AnimatedTabs as segment-style with sliding indicator - Upgrade SidebarFilesTree to match FilesView features (context menus, git status, file icons, CRUD dialogs, fuzzy search ranking) - Restructure FilesView header: tabs row + actions row, remove breadcrumbs - Show relative path in context panel header, track active tab - Allow left sidebar to stay open alongside context panel - Hide diff/files tabs from header on desktop (mobile-only) - Move chevron after group name in session sidebar - Compact tab heights in right sidebar and git view - Size PreviewToggleButton to match other action buttons - Remove directory loading spinner from folder icons * feat: add project icon and color customization - Enable users to assign custom icons to projects - Allow users to choose accent colors for projects - Stabilize repo status UI during project switching * feat: add scroll fade indicators to editor tabs * style: reduce spacing and icon sizes in header * style: adjust tab component padding from uniform to vertical-horizontal * feat: Add session state indicators to project tabs * feat: Enhance session status handling and improve UI responsiveness * fix: preserve upstream tracking on branch rename * fix: improve initial remote selection for pull requests - Uses saved remote name from previous session when available - Selects remote based on tracking branch when possible - Falls back to origin or first available remote * perf(diff): faster highlight, stable stacked scroll - split/unified Pierre worker pools; prefer shiki-wasm - align diff CSS line-height; disable scroll anchoring; drop WebKit compositing hacks - harden stacked pin/align (cancel on user scroll/input); prevent overscroll - make overlay scrollbar MutationObserver optional; disable for diff container * feat: handle binary files in diff view * fix: adjust project tabs layout and drag regions * style: update drag overlay visual styling * feat: enable number keys to switch projects in the sidebar * fix: recognize octet-stream as text-based MIME type * feat: add keyboard navigation to context panel * feat: add session pinning to sidebar - Pin important sessions to keep them at the top - Pinned sessions persist across browser sessions * refactor: move context usage display from chat input to header
This commit is contained in:
committed by
GitHub
parent
12606b9e53
commit
47c943b487
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DiffView, FilesView } from '@/components/views';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
const CONTEXT_PANEL_MIN_WIDTH = 360;
|
||||
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
||||
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
|
||||
|
||||
const normalizeDirectoryKey = (value: string): string => {
|
||||
if (!value) return '';
|
||||
|
||||
const raw = value.replace(/\\/g, '/');
|
||||
const hadUncPrefix = raw.startsWith('//');
|
||||
let normalized = raw.replace(/\/+$/g, '');
|
||||
normalized = normalized.replace(/\/+/g, '/');
|
||||
|
||||
if (hadUncPrefix && !normalized.startsWith('//')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
if (normalized === '') {
|
||||
return raw.startsWith('/') ? '/' : '';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const clampWidth = (width: number): number => {
|
||||
if (!Number.isFinite(width)) {
|
||||
return CONTEXT_PANEL_DEFAULT_WIDTH;
|
||||
}
|
||||
|
||||
return Math.min(CONTEXT_PANEL_MAX_WIDTH, Math.max(CONTEXT_PANEL_MIN_WIDTH, Math.round(width)));
|
||||
};
|
||||
|
||||
const getRelativePathLabel = (filePath: string | null, directory: string): string => {
|
||||
if (!filePath) {
|
||||
return '';
|
||||
}
|
||||
const normalizedFile = filePath.replace(/\\/g, '/');
|
||||
const normalizedDir = directory.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (normalizedDir && normalizedFile.startsWith(normalizedDir + '/')) {
|
||||
return normalizedFile.slice(normalizedDir.length + 1);
|
||||
}
|
||||
return normalizedFile;
|
||||
};
|
||||
|
||||
export const ContextPanel: React.FC = () => {
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const directoryKey = React.useMemo(() => normalizeDirectoryKey(effectiveDirectory), [effectiveDirectory]);
|
||||
|
||||
const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined));
|
||||
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
||||
const toggleContextPanelExpanded = useUIStore((state) => state.toggleContextPanelExpanded);
|
||||
const setContextPanelWidth = useUIStore((state) => state.setContextPanelWidth);
|
||||
|
||||
const isOpen = Boolean(panelState?.isOpen && panelState?.mode);
|
||||
const isExpanded = Boolean(isOpen && panelState?.expanded);
|
||||
const width = clampWidth(panelState?.width ?? CONTEXT_PANEL_DEFAULT_WIDTH);
|
||||
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(width);
|
||||
const panelRef = React.useRef<HTMLElement | null>(null);
|
||||
const wasOpenRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || wasOpenRef.current) {
|
||||
wasOpenRef.current = isOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
panelRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
wasOpenRef.current = true;
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [isOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isResizing || !directoryKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const delta = startXRef.current - event.clientX;
|
||||
setContextPanelWidth(directoryKey, startWidthRef.current + delta);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
}, [directoryKey, isResizing, setContextPanelWidth]);
|
||||
|
||||
const handleResizeStart = React.useCallback((event: React.PointerEvent) => {
|
||||
if (!isOpen || isExpanded || !directoryKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
startXRef.current = event.clientX;
|
||||
startWidthRef.current = width;
|
||||
event.preventDefault();
|
||||
}, [directoryKey, isExpanded, isOpen, width]);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (!directoryKey) {
|
||||
return;
|
||||
}
|
||||
closeContextPanel(directoryKey);
|
||||
}, [closeContextPanel, directoryKey]);
|
||||
|
||||
const handleToggleExpanded = React.useCallback(() => {
|
||||
if (!directoryKey) {
|
||||
return;
|
||||
}
|
||||
toggleContextPanelExpanded(directoryKey);
|
||||
}, [directoryKey, toggleContextPanelExpanded]);
|
||||
|
||||
const handlePanelKeyDownCapture = React.useCallback((event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleClose();
|
||||
}, [handleClose]);
|
||||
|
||||
const activeFilePath = useFilesViewTabsStore((state) => (directoryKey ? (state.byRoot[directoryKey]?.selectedPath ?? null) : null));
|
||||
|
||||
const panelTitle = panelState?.mode === 'diff' ? 'Diff' : panelState?.mode === 'file' ? 'File' : 'Panel';
|
||||
const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : (panelState?.targetPath ?? null);
|
||||
const pathLabel = getRelativePathLabel(effectivePath, effectiveDirectory);
|
||||
|
||||
const content = panelState?.mode === 'diff'
|
||||
? <DiffView hideStackedFileSidebar stackedDefaultCollapsedAll hideFileSelector pinSelectedFileHeaderToTopOnNavigate />
|
||||
: panelState?.mode === 'file'
|
||||
? <FilesView mode="editor-only" />
|
||||
: null;
|
||||
|
||||
const header = (
|
||||
<header className="flex h-10 items-center gap-2 border-b border-border/40 px-2.5">
|
||||
<div className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
|
||||
<span>{panelTitle}</span>
|
||||
{pathLabel ? <span className="ml-2 text-muted-foreground">{pathLabel}</span> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleExpanded}
|
||||
className="h-6 w-6 p-0"
|
||||
title={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
||||
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
||||
>
|
||||
{isExpanded ? <RiFullscreenExitLine className="h-3.5 w-3.5" /> : <RiFullscreenLine className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
className="h-6 w-6 p-0"
|
||||
title="Close panel"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
data-context-panel="true"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col overflow-hidden border-l border-border bg-background',
|
||||
isExpanded
|
||||
? 'absolute inset-0 z-20 min-w-0'
|
||||
: 'relative h-full flex-shrink-0',
|
||||
isResizing ? 'transition-none' : 'transition-[width] duration-200 ease-in-out'
|
||||
)}
|
||||
onKeyDownCapture={handlePanelKeyDownCapture}
|
||||
style={isExpanded
|
||||
? undefined
|
||||
: {
|
||||
width: `${width}px`,
|
||||
minWidth: `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
}}
|
||||
>
|
||||
{!isExpanded && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-0 z-20 h-full w-[4px] cursor-col-resize transition-colors hover:bg-primary/50',
|
||||
isResizing && 'bg-primary'
|
||||
)}
|
||||
onPointerDown={handleResizeStart}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize context panel"
|
||||
/>
|
||||
)}
|
||||
{header}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{content}</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user