* fix(plan-comments): restore embedded inline comment widgets in Plan view Reinstate CodeMirror block-widget comments so plan annotations stay anchored to selected lines and preserve drag/selection behavior without floating overlays. * fix(file-comments): return Files editor comments to embedded widgets Use inline block widgets for file drafts while keeping full-path draft scoping, so similarly named files no longer risk comment collisions. * fix(diff-comments): render inline comments through annotation portals Replace absolute floating positioning with annotation-target portals to keep diff comments attached to their lines across shadow DOM updates. * chore(comments): remove deprecated floating comment hook Drop the unused floating-comment implementation now that plan, file, and diff views all use embedded comment rendering paths. * fix(codemirror): expose gutter width as CSS variable Measure the current CodeMirror gutter and publish --oc-editor-gutter-width on the editor host so inline widgets can size to the visible code area without hardcoded dimensions. * fix(context-panel): publish panel width for embedded widgets Set --oc-context-panel-width on the context panel in both docked and expanded modes so comment widgets can follow the active panel width dynamically. * fix(file-comments): constrain inline input to editor content width Use context-panel and editor-gutter CSS variables to cap comment input width to the visible editor content area, keeping action buttons fully visible in no-wrap mode. * fix(file-comments): constrain inline comment cards to content area Apply the same variable-based width cap to saved comment cards so card actions stay visible when long lines force horizontal scrolling. * fix(diff-comments): stabilize new comment annotation identity Derive new-comment annotation ids from selection side and line range, and reuse that id for portal target lookup and keys to avoid remount glitches.
240 lines
8.0 KiB
TypeScript
240 lines
8.0 KiB
TypeScript
import React from 'react';
|
|
import { RiCloseLine, RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { DiffView, FilesView, PlanView } from '@/components/views';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import { cn } from '@/lib/utils';
|
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { ContextPanelContent } from './ContextSidebarTab';
|
|
|
|
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' : panelState?.mode === 'context' ? 'Context' : panelState?.mode === 'plan' ? 'Plan' : 'Panel';
|
|
const effectivePath = panelState?.mode === 'file' ? (activeFilePath ?? panelState?.targetPath ?? null) : panelState?.mode === 'context' ? 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" />
|
|
: panelState?.mode === 'context'
|
|
? <ContextPanelContent />
|
|
: panelState?.mode === 'plan'
|
|
? <PlanView />
|
|
: 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;
|
|
}
|
|
|
|
const panelStyle: React.CSSProperties = isExpanded
|
|
? {
|
|
['--oc-context-panel-width' as string]: '100vw',
|
|
}
|
|
: {
|
|
width: `${width}px`,
|
|
minWidth: `${width}px`,
|
|
maxWidth: `${width}px`,
|
|
['--oc-context-panel-width' as string]: `${width}px`,
|
|
};
|
|
|
|
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={panelStyle}
|
|
>
|
|
{!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>
|
|
);
|
|
};
|