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:
Bohdan Triapitsyn
2026-02-16 14:15:19 +02:00
committed by GitHub
parent 12606b9e53
commit 47c943b487
42 changed files with 4874 additions and 1163 deletions
+440 -170
View File
@@ -16,7 +16,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
@@ -44,7 +44,15 @@ type FileEntry = GitStatus['files'][number] & {
isNew: boolean;
};
type DiffData = { original: string; modified: string };
type DiffData = { original: string; modified: string; isBinary?: boolean };
const BinaryDiffPlaceholder = React.memo(() => {
return (
<div className="rounded-lg border border-border/60 bg-background px-3 py-2">
<div className="typography-meta text-muted-foreground">Content of this file cannot be viewed.</div>
</div>
);
});
type DiffTabViewMode = 'single' | 'stacked';
@@ -426,7 +434,7 @@ interface InlineDiffViewerProps {
wrapLines: boolean;
}
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
filePath,
diff,
renderSideBySide,
@@ -437,6 +445,10 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
[filePath]
);
if (diff.isBinary) {
return <BinaryDiffPlaceholder />;
}
if (isImageFile(filePath)) {
return (
<InlineImageDiffViewer
@@ -471,7 +483,7 @@ interface SingleDiffViewerProps {
wrapLines: boolean;
}
const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
filePath,
diff,
isVisible,
@@ -483,6 +495,10 @@ const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
[filePath]
);
if (diff.isBinary) {
return <BinaryDiffPlaceholder />;
}
// Don't render if not visible (memory optimization)
if (!isVisible) {
return null;
@@ -514,45 +530,6 @@ const SingleDiffViewer = React.memo<SingleDiffViewerProps>(({
);
});
interface DiffViewerEntryProps {
directory: string;
filePath: string;
isVisible: boolean;
renderSideBySide: boolean;
wrapLines: boolean;
}
const DiffViewerEntry = React.memo<DiffViewerEntryProps>(({
directory,
filePath,
isVisible,
renderSideBySide,
wrapLines,
}) => {
const cachedDiff = useGitStore(
React.useCallback((state) => {
return state.directories.get(directory)?.diffCache.get(filePath) ?? null;
}, [directory, filePath])
);
const diffData = React.useMemo(() => {
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified };
}, [cachedDiff]);
if (!diffData) return null;
return (
<SingleDiffViewer
filePath={filePath}
diff={diffData}
isVisible={isVisible}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
);
});
interface MultiFileDiffEntryProps {
directory: string;
file: FileEntry;
@@ -564,6 +541,8 @@ interface MultiFileDiffEntryProps {
registerSectionRef: (path: string, node: HTMLDivElement | null) => void;
/** Start collapsed to reduce memory with many files */
defaultCollapsed?: boolean;
expandRequestPath?: string | null;
expandRequestNonce?: number;
}
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
@@ -576,6 +555,8 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
onSelect,
registerSectionRef,
defaultCollapsed = false,
expandRequestPath = null,
expandRequestNonce = 0,
}) => {
const { git } = useRuntimeAPIs();
const cachedDiff = useGitStore(
@@ -597,9 +578,9 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
const descriptor = React.useMemo(() => describeChange(file), [file]);
const renderSideBySide = layout === 'side-by-side';
const diffData = React.useMemo(() => {
const diffData = React.useMemo<DiffData | null>(() => {
if (!cachedDiff) return null;
return { original: cachedDiff.original, modified: cachedDiff.modified };
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary };
}, [cachedDiff]);
const setSectionRef = React.useCallback((node: HTMLDivElement | null) => {
@@ -642,6 +623,15 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
return () => observer.disconnect();
}, [hasBeenVisible, isExpanded, scrollRootRef]);
React.useEffect(() => {
if (expandRequestNonce <= 0 || expandRequestPath !== file.path) {
return;
}
setIsExpanded(true);
setHasBeenVisible(true);
}, [expandRequestNonce, expandRequestPath, file.path]);
React.useEffect(() => {
if (!isExpanded || !hasBeenVisible) return;
if (!directory || diffData) {
@@ -673,6 +663,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
setDiff(directory, file.path, {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
});
setIsLoading(false);
} catch (error) {
@@ -691,108 +682,116 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
};
}, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff]);
const handleToggle = React.useCallback(() => {
handleOpenChange(!isExpanded);
handleSelect();
}, [handleOpenChange, handleSelect, isExpanded]);
return (
<div ref={setSectionRef} className="scroll-mt-4">
<Collapsible
open={isExpanded}
onOpenChange={handleOpenChange}
className="group/collapsible"
>
<div className="sticky top-0 z-10 bg-background">
<CollapsibleTrigger
onClick={handleSelect}
className={cn(
'relative flex w-full items-center gap-2 px-3 py-1.5 transition-colors rounded-t-xl border border-border/60 overflow-hidden',
'bg-background hover:bg-background',
isExpanded ? 'rounded-b-none' : 'rounded-b-xl',
isSelected
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<div className={cn(
'absolute inset-0 pointer-events-none transition-colors',
isSelected ? 'bg-interactive-selection' : 'group-hover:bg-interactive-hover'
)} />
<div className="relative flex min-w-0 flex-1 items-center gap-2">
<span className="flex size-5 items-center justify-center opacity-70 group-hover:opacity-100 transition-opacity">
{isExpanded ? (
<RiArrowDownSLine className="size-4" />
) : (
<RiArrowRightSLine className="size-4" />
)}
</span>
<span
className="typography-micro font-semibold w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
>
{descriptor.code}
</span>
<span
className="min-w-0 flex-1 truncate typography-ui-label"
style={{ direction: 'rtl', textAlign: 'left' }}
title={file.path}
>
{file.path}
</span>
</div>
<div className="relative flex items-center gap-2">
{formatDiffTotals(file.insertions, file.deletions)}
<DiffViewToggle
mode={renderSideBySide ? 'side-by-side' : 'unified'}
onModeChange={(mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
setDiffFileLayout(file.path, nextLayout);
}}
className="opacity-70"
/>
</div>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="relative border border-t-0 border-border/60 bg-background rounded-b-xl overflow-hidden">
{diffLoadError ? (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<div className="typography-ui-label font-semibold text-foreground">
Failed to load diff
</div>
<div className="typography-meta text-muted-foreground max-w-[32rem] text-center">
{diffLoadError}
</div>
<button
type="button"
className="typography-ui-label text-primary hover:underline"
onClick={() => setDiffRetryNonce((nonce) => nonce + 1)}
>
Retry
</button>
</div>
) : null}
{isLoading && !diffData && !diffLoadError ? (
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading diff
</div>
) : null}
{isExpanded && diffData ? (
<InlineDiffViewer
filePath={file.path}
diff={diffData}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
) : null}
<div className="sticky top-0 z-10 bg-background">
<button
type="button"
onClick={handleToggle}
className={cn(
'group/header relative flex w-full items-center gap-2 px-3 py-1.5 rounded-t-xl border border-border/60 overflow-hidden',
'bg-background',
isExpanded ? 'rounded-b-none' : 'rounded-b-xl',
'text-muted-foreground hover:text-foreground',
isSelected ? 'ring-1 ring-inset ring-[var(--interactive-selection)]' : null
)}
>
<div className="absolute inset-0 pointer-events-none group-hover/header:bg-interactive-hover" />
<div className="relative flex min-w-0 flex-1 items-center gap-2">
<span className="flex size-5 items-center justify-center opacity-70 group-hover/header:opacity-100">
{isExpanded ? (
<RiArrowDownSLine className="size-4" />
) : (
<RiArrowRightSLine className="size-4" />
)}
</span>
<span
className="typography-micro font-semibold w-4 text-center uppercase"
style={{ color: descriptor.color }}
title={descriptor.description}
aria-label={descriptor.description}
>
{descriptor.code}
</span>
<span
className="min-w-0 flex-1 truncate typography-ui-label"
style={{ direction: 'rtl', textAlign: 'left' }}
title={file.path}
>
{file.path}
</span>
</div>
</CollapsibleContent>
</Collapsible>
<div className="relative flex items-center gap-2">
{formatDiffTotals(file.insertions, file.deletions)}
<DiffViewToggle
mode={renderSideBySide ? 'side-by-side' : 'unified'}
onModeChange={(mode: DiffViewMode) => {
const nextLayout: 'inline' | 'side-by-side' =
mode === 'side-by-side' ? 'side-by-side' : 'inline';
setDiffFileLayout(file.path, nextLayout);
}}
className="opacity-70"
/>
</div>
</button>
</div>
{isExpanded && (
<div className="relative border border-t-0 border-border/60 bg-background rounded-b-xl overflow-hidden">
{diffLoadError ? (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<div className="typography-ui-label font-semibold text-foreground">
Failed to load diff
</div>
<div className="typography-meta text-muted-foreground max-w-[32rem] text-center">
{diffLoadError}
</div>
<button
type="button"
className="typography-ui-label text-primary hover:underline"
onClick={() => setDiffRetryNonce((nonce) => nonce + 1)}
>
Retry
</button>
</div>
) : null}
{isLoading && !diffData && !diffLoadError ? (
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground">
<RiLoader4Line size={16} className="animate-spin" />
Loading diff
</div>
) : null}
{diffData ? (
<InlineDiffViewer
filePath={file.path}
diff={diffData}
renderSideBySide={renderSideBySide}
wrapLines={wrapLines}
/>
) : null}
</div>
)}
</div>
);
});
export const DiffView: React.FC = () => {
interface DiffViewProps {
hideStackedFileSidebar?: boolean;
stackedDefaultCollapsedAll?: boolean;
hideFileSelector?: boolean;
pinSelectedFileHeaderToTopOnNavigate?: boolean;
}
export const DiffView: React.FC<DiffViewProps> = ({
hideStackedFileSidebar = false,
stackedDefaultCollapsedAll = false,
hideFileSelector = false,
pinSelectedFileHeaderToTopOnNavigate = false,
}) => {
const { git } = useRuntimeAPIs();
const effectiveDirectory = useEffectiveDirectory();
const { screenWidth, isMobile } = useDeviceInfo();
@@ -803,6 +802,9 @@ export const DiffView: React.FC = () => {
const { setActiveDirectory, fetchStatus, setDiff } = useGitStore();
const [selectedFile, setSelectedFile] = React.useState<string | null>(null);
const [stackedExpandTarget, setStackedExpandTarget] = React.useState<string | null>(null);
const [stackedExpandRequestNonce, setStackedExpandRequestNonce] = React.useState(0);
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const lastDiffRequestRef = React.useRef<string | null>(null);
@@ -821,10 +823,108 @@ export const DiffView: React.FC = () => {
const isStackedView = diffViewMode === 'stacked';
const isMobileLayout = isMobile || screenWidth <= 768;
const showFileSidebar = !isMobileLayout && screenWidth >= 1024;
const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024;
const diffScrollRef = React.useRef<HTMLElement | null>(null);
const fileSectionRefs = React.useRef(new Map<string, HTMLDivElement | null>());
const pendingScrollTargetRef = React.useRef<string | null>(null);
const pendingScrollFrameRef = React.useRef<number | null>(null);
const shouldPinAfterAlignRef = React.useRef(false);
React.useEffect(() => {
if (!pinSelectedFileHeaderToTopOnNavigate || !isStackedView || !pinnedStackedTarget) {
return;
}
const scrollRoot = diffScrollRef.current;
if (!scrollRoot) {
return;
}
let rafId: number | null = null;
let cancelled = false;
let stableFrames = 0;
const stopAt = Date.now() + 1200;
let ignoreNextScrollEvents = 0;
const stop = () => {
if (cancelled) {
return;
}
cancelled = true;
setPinnedStackedTarget(null);
};
const cancelOnUserInput = () => {
stop();
};
const cancelOnScroll = () => {
if (ignoreNextScrollEvents > 0) {
ignoreNextScrollEvents -= 1;
return;
}
stop();
};
window.addEventListener('wheel', cancelOnUserInput, { passive: true, capture: true });
window.addEventListener('touchstart', cancelOnUserInput, { passive: true, capture: true });
window.addEventListener('pointerdown', cancelOnUserInput, { capture: true });
window.addEventListener('keydown', cancelOnUserInput, { capture: true });
scrollRoot.addEventListener('scroll', cancelOnScroll, { passive: true });
const tick = () => {
if (cancelled || Date.now() > stopAt) {
stop();
return;
}
const currentScrollRoot = diffScrollRef.current;
const node = fileSectionRefs.current.get(pinnedStackedTarget);
if (!currentScrollRoot || !node) {
stop();
return;
}
const rootRect = currentScrollRoot.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const delta = nodeRect.top - rootRect.top;
if (Math.abs(delta) <= 1) {
stableFrames += 1;
if (stableFrames >= 2) {
stop();
return;
}
} else {
stableFrames = 0;
const maxTop = Math.max(0, currentScrollRoot.scrollHeight - currentScrollRoot.clientHeight);
const nextTop = Math.min(maxTop, Math.max(0, currentScrollRoot.scrollTop + delta));
if (Math.abs(nextTop - currentScrollRoot.scrollTop) <= 0.5) {
stop();
return;
}
ignoreNextScrollEvents += 1;
currentScrollRoot.scrollTop = nextTop;
}
rafId = window.requestAnimationFrame(tick);
};
rafId = window.requestAnimationFrame(tick);
return () => {
cancelled = true;
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
window.removeEventListener('wheel', cancelOnUserInput, true);
window.removeEventListener('touchstart', cancelOnUserInput, true);
window.removeEventListener('pointerdown', cancelOnUserInput, true);
window.removeEventListener('keydown', cancelOnUserInput, true);
scrollRoot.removeEventListener('scroll', cancelOnScroll);
};
}, [isStackedView, pinSelectedFileHeaderToTopOnNavigate, pinnedStackedTarget]);
const changedFiles: FileEntry[] = React.useMemo(() => {
if (!status?.files) return [];
@@ -887,7 +987,10 @@ export const DiffView: React.FC = () => {
setSelectedFile(pendingDiffFile);
setPendingDiffFile(null);
if (isStackedView) {
shouldPinAfterAlignRef.current = true;
pendingScrollTargetRef.current = pendingDiffFile;
setStackedExpandTarget(pendingDiffFile);
setStackedExpandRequestNonce((nonce) => nonce + 1);
}
}
}, [isStackedView, pendingDiffFile, setPendingDiffFile]);
@@ -899,22 +1002,6 @@ export const DiffView: React.FC = () => {
}
}, [changedFiles, selectedFile, pendingDiffFile]);
React.useEffect(() => {
if (!isStackedView) {
pendingScrollTargetRef.current = null;
return;
}
const target = pendingScrollTargetRef.current;
if (!target) return;
const node = fileSectionRefs.current.get(target);
if (!node) return;
node.scrollIntoView({ behavior: 'smooth', block: 'start' });
pendingScrollTargetRef.current = null;
}, [changedFiles, isStackedView]);
// Clear selection if file no longer exists
React.useEffect(() => {
if (selectedFile && changedFiles.length > 0) {
@@ -934,29 +1021,202 @@ export const DiffView: React.FC = () => {
}
}, []);
const scrollToFile = React.useCallback((path: string, behavior: ScrollBehavior = 'smooth') => {
type ScrollToFileResult = {
ok: boolean;
aligned: boolean;
didMove: boolean;
atScrollLimit: boolean;
delta: number;
};
const scrollToFile = React.useCallback((path: string): ScrollToFileResult => {
const node = fileSectionRefs.current.get(path);
if (!node) return false;
node.scrollIntoView({ behavior, block: 'start' });
return true;
const scrollRoot = diffScrollRef.current;
if (!node || !scrollRoot) {
return { ok: false, aligned: false, didMove: false, atScrollLimit: false, delta: 0 };
}
const rootRect = scrollRoot.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();
const delta = nodeRect.top - rootRect.top;
const maxTop = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight);
const desiredTop = scrollRoot.scrollTop + delta;
const nextTop = Math.min(maxTop, Math.max(0, desiredTop));
const didMove = Math.abs(nextTop - scrollRoot.scrollTop) > 0.5;
scrollRoot.scrollTop = nextTop;
const aligned = Math.abs(delta) <= 1;
const atScrollLimit = nextTop <= 0.5 || nextTop >= maxTop - 0.5;
return { ok: true, aligned, didMove, atScrollLimit, delta };
}, []);
React.useEffect(() => {
if (!isStackedView) {
pendingScrollTargetRef.current = null;
shouldPinAfterAlignRef.current = false;
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
return;
}
const target = pendingScrollTargetRef.current;
if (!target) return;
let attempts = 0;
const maxAttempts = 120;
let cancelled = false;
let ignoreNextScrollEvents = 0;
let didRemoveListeners = false;
let stallFrames = 0;
const stopAt = Date.now() + 2000;
const removeListeners = () => {
if (didRemoveListeners) {
return;
}
didRemoveListeners = true;
window.removeEventListener('wheel', cancelOnUserInput, true);
window.removeEventListener('touchstart', cancelOnUserInput, true);
window.removeEventListener('pointerdown', cancelOnUserInput, true);
window.removeEventListener('keydown', cancelOnUserInput, true);
scrollRoot?.removeEventListener('scroll', cancelOnScroll);
};
const cancelPending = () => {
if (cancelled) {
return;
}
cancelled = true;
removeListeners();
pendingScrollTargetRef.current = null;
shouldPinAfterAlignRef.current = false;
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
};
const cancelOnUserInput = () => {
cancelPending();
};
const cancelOnScroll = () => {
if (ignoreNextScrollEvents > 0) {
ignoreNextScrollEvents -= 1;
return;
}
cancelPending();
};
const scrollRoot = diffScrollRef.current;
window.addEventListener('wheel', cancelOnUserInput, { passive: true, capture: true });
window.addEventListener('touchstart', cancelOnUserInput, { passive: true, capture: true });
window.addEventListener('pointerdown', cancelOnUserInput, { capture: true });
window.addEventListener('keydown', cancelOnUserInput, { capture: true });
scrollRoot?.addEventListener('scroll', cancelOnScroll, { passive: true });
const tryAlign = () => {
if (Date.now() > stopAt) {
cancelPending();
pendingScrollFrameRef.current = null;
return;
}
if (cancelled) {
pendingScrollFrameRef.current = null;
return;
}
const currentTarget = pendingScrollTargetRef.current;
if (!currentTarget) {
cancelPending();
pendingScrollFrameRef.current = null;
return;
}
ignoreNextScrollEvents += 1;
const result = scrollToFile(currentTarget);
if (!result.ok) {
ignoreNextScrollEvents = Math.max(0, ignoreNextScrollEvents - 1);
attempts += 1;
if (attempts < maxAttempts) {
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
} else {
cancelPending();
pendingScrollFrameRef.current = null;
}
return;
}
if (!result.aligned) {
attempts += 1;
if (!result.didMove) {
stallFrames += 1;
// If we're clamped (e.g. target is near bottom) give layout a few frames to settle
// (diff expansion / highlight can change scrollHeight), but don't fight user input.
if (stallFrames < 6 && (result.atScrollLimit || Math.abs(result.delta) > 1)) {
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
return;
}
} else {
stallFrames = 0;
if (attempts < maxAttempts) {
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
return;
}
}
}
if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) {
setPinnedStackedTarget(currentTarget);
}
cancelPending();
};
pendingScrollFrameRef.current = window.requestAnimationFrame(tryAlign);
return () => {
cancelled = true;
removeListeners();
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
};
}, [isStackedView, pinSelectedFileHeaderToTopOnNavigate, scrollToFile, selectedFile, stackedExpandRequestNonce]);
const handleSelectFile = React.useCallback((value: string) => {
setSelectedFile(value);
}, []);
const handleSelectFileAndScroll = React.useCallback((value: string) => {
if (pendingScrollFrameRef.current !== null) {
window.cancelAnimationFrame(pendingScrollFrameRef.current);
pendingScrollFrameRef.current = null;
}
pendingScrollTargetRef.current = null;
setSelectedFile(value);
if (isStackedView && !scrollToFile(value)) {
pendingScrollTargetRef.current = value;
if (!isStackedView) {
shouldPinAfterAlignRef.current = false;
return;
}
shouldPinAfterAlignRef.current = true;
pendingScrollTargetRef.current = value;
scrollToFile(value);
}, [isStackedView, scrollToFile]);
const handleDiffViewModeChange = React.useCallback((mode: DiffTabViewMode) => {
setDiffViewMode(mode);
if (mode === 'stacked' && selectedFile && !scrollToFile(selectedFile, 'auto')) {
pendingScrollTargetRef.current = selectedFile;
if (mode === 'stacked' && selectedFile) {
const result = scrollToFile(selectedFile);
if (!result.aligned) {
pendingScrollTargetRef.current = selectedFile;
}
}
}, [scrollToFile, selectedFile, setDiffViewMode]);
@@ -976,13 +1236,18 @@ export const DiffView: React.FC = () => {
}, [changedFiles, isStackedView, selectedFileEntry, setDiffFileLayout]);
const renderSideBySide = (currentLayoutForSelectedFile ?? 'side-by-side') === 'side-by-side';
const showFileSelector = !isStackedView || !showFileSidebar;
const showFileSelector = !hideFileSelector && (!isStackedView || !showFileSidebar);
const selectedCachedDiff = useGitStore(React.useCallback((state) => {
if (!effectiveDirectory || !selectedFile) return null;
return state.directories.get(effectiveDirectory)?.diffCache.get(selectedFile) ?? null;
}, [effectiveDirectory, selectedFile]));
const selectedDiffData = React.useMemo<DiffData | null>(() => {
if (!selectedCachedDiff) return null;
return { original: selectedCachedDiff.original, modified: selectedCachedDiff.modified, isBinary: selectedCachedDiff.isBinary };
}, [selectedCachedDiff]);
const hasCurrentDiff = !!selectedCachedDiff;
const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff;
@@ -1024,6 +1289,7 @@ export const DiffView: React.FC = () => {
setDiff(effectiveDirectory, selectedFile, {
original: response.original ?? '',
modified: response.modified ?? '',
isBinary: response.isBinary,
});
} catch (error) {
if (cancelled) return;
@@ -1043,13 +1309,13 @@ export const DiffView: React.FC = () => {
// Render only the selected diff viewer to prevent memory bloat with many files
const renderSelectedDiffViewer = () => {
if (!effectiveDirectory || !selectedFile) return null;
if (!effectiveDirectory || !selectedFile || !selectedDiffData) return null;
return (
<DiffViewerEntry
<SingleDiffViewer
key={selectedFile}
directory={effectiveDirectory}
filePath={selectedFile}
diff={selectedDiffData}
isVisible={true}
renderSideBySide={renderSideBySide}
wrapLines={diffWrapLines}
@@ -1082,6 +1348,8 @@ export const DiffView: React.FC = () => {
outerClassName="flex-1 min-h-0 h-full"
className="pr-2"
disableHorizontal
observeMutations={false}
preventOverscroll
data-diff-virtual-root
data-diff-virtual-content
>
@@ -1097,7 +1365,9 @@ export const DiffView: React.FC = () => {
isSelected={file.path === selectedFile}
onSelect={handleSelectFile}
registerSectionRef={registerSectionRef}
defaultCollapsed={index >= defaultExpandedCount}
defaultCollapsed={stackedDefaultCollapsedAll ? true : index >= defaultExpandedCount}
expandRequestPath={stackedExpandTarget}
expandRequestNonce={stackedExpandRequestNonce}
/>
))}
</div>
+232 -294
View File
@@ -2,7 +2,6 @@ import React from 'react';
import {
RiArrowLeftSLine,
RiArrowRightSLine,
RiArrowDownSLine,
RiClipboardLine,
RiCloseLine,
@@ -39,7 +38,7 @@ import {
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
@@ -66,7 +65,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
import { useGitStatus } from '@/stores/useGitStore';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
import { useFloatingComments } from '@/components/comments/useFloatingComments';
import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
@@ -154,52 +153,6 @@ const getAncestorPaths = (filePath: string, root: string): string[] => {
return ancestors;
};
type BreadcrumbSegment = { label: string; path: string };
const parseBreadcrumbs = (relativePath: string, root: string): BreadcrumbSegment[] => {
const parts = relativePath.split('/');
const segments: BreadcrumbSegment[] = [];
let currentPath = root;
for (const part of parts) {
if (!part) continue;
currentPath = currentPath ? `${currentPath}/${part}` : part;
segments.push({ label: part, path: currentPath });
}
return segments;
};
const FileBreadcrumbs: React.FC<{
path: string;
root: string;
onNavigate: (dirPath: string) => void;
}> = ({ path, root, onNavigate }) => {
const segments = React.useMemo(() => parseBreadcrumbs(path, root), [path, root]);
return (
<div className="flex items-center gap-1 overflow-x-auto whitespace-nowrap min-w-0 flex-1 hide-scrollbar">
{segments.map((seg, i) => (
<React.Fragment key={seg.path}>
{i > 0 && <RiArrowRightSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />}
<button
type="button"
onClick={() => i < segments.length - 1 && onNavigate(seg.path)}
className={cn(
"typography-meta transition-colors",
i === segments.length - 1
? "text-foreground font-medium cursor-default"
: "text-muted-foreground hover:text-foreground hover:underline cursor-pointer"
)}
disabled={i === segments.length - 1}
>
{seg.label}
</button>
</React.Fragment>
))}
</div>
);
};
const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']);
type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted';
@@ -375,7 +328,6 @@ interface FileRowProps {
node: FileNode;
isExpanded: boolean;
isActive: boolean;
isLoading: boolean;
isMobile: boolean;
status?: FileStatus | null;
badge?: { modified: number; added: number } | null;
@@ -396,7 +348,6 @@ const FileRow: React.FC<FileRowProps> = ({
node,
isExpanded,
isActive,
isLoading,
isMobile,
status,
badge,
@@ -446,9 +397,7 @@ const FileRow: React.FC<FileRowProps> = ({
)}
>
{isDir ? (
isLoading ? (
<RiLoader4Line className="h-4 w-4 flex-shrink-0 animate-spin" />
) : isExpanded ? (
isExpanded ? (
<RiFolderOpenFill className="h-4 w-4 flex-shrink-0 text-primary/60" />
) : (
<RiFolder3Fill className="h-4 w-4 flex-shrink-0 text-primary/60" />
@@ -537,7 +486,11 @@ const FileRow: React.FC<FileRowProps> = ({
);
};
export const FilesView: React.FC = () => {
interface FilesViewProps {
mode?: 'full' | 'editor-only';
}
export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const { files, runtime } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
@@ -587,6 +540,30 @@ export const FilesView: React.FC = () => {
const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]);
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
// Editor tabs horizontal scroll fades
const editorTabsScrollRef = React.useRef<HTMLDivElement>(null);
const [editorTabsOverflow, setEditorTabsOverflow] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false });
const updateEditorTabsOverflow = React.useCallback(() => {
const el = editorTabsScrollRef.current;
if (!el) return;
setEditorTabsOverflow({
left: el.scrollLeft > 2,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 2,
});
}, []);
React.useEffect(() => {
const el = editorTabsScrollRef.current;
if (!el) return;
updateEditorTabsOverflow();
el.addEventListener('scroll', updateEditorTabsOverflow, { passive: true });
const ro = new ResizeObserver(updateEditorTabsOverflow);
ro.observe(el);
return () => {
el.removeEventListener('scroll', updateEditorTabsOverflow);
ro.disconnect();
};
}, [updateEditorTabsOverflow, openFiles.length]);
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
@@ -612,6 +589,7 @@ export const FilesView: React.FC = () => {
const copiedContentTimeoutRef = React.useRef<number | null>(null);
const copiedPathTimeoutRef = React.useRef<number | null>(null);
const editorViewRef = React.useRef<EditorView | null>(null);
const editorWrapperRef = React.useRef<HTMLDivElement | null>(null);
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null);
const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null);
@@ -1542,17 +1520,6 @@ export const FilesView: React.FC = () => {
}
}, [loadDirectory, root, toggleExpandedPath]);
const handleBreadcrumbNavigate = React.useCallback((dirPath: string) => {
if (!root) return;
if (searchQuery.trim().length > 0) {
setSearchQuery('');
}
if (isMobile) {
setShowMobilePageContent(false);
}
void ensurePathVisible(dirPath, true);
}, [ensurePathVisible, isMobile, root, searchQuery]);
const renderTree = React.useCallback((dirPath: string, depth: number): React.ReactNode => {
const nodes = childrenByDir[dirPath] ?? [];
@@ -1560,7 +1527,6 @@ export const FilesView: React.FC = () => {
const isDir = node.type === 'directory';
const isExpanded = isDir && expandedPaths.includes(node.path);
const isActive = selectedFile?.path === node.path;
const isLoading = isDir && inFlightDirsRef.current.has(node.path);
const isLast = index === nodes.length - 1;
return (
@@ -1577,7 +1543,6 @@ export const FilesView: React.FC = () => {
node={node}
isExpanded={isExpanded}
isActive={isActive}
isLoading={isLoading}
isMobile={isMobile}
status={!isDir ? getFileStatus(node.path) : undefined}
badge={isDir ? getFolderBadge(node.path) : undefined}
@@ -1798,9 +1763,9 @@ export const FilesView: React.FC = () => {
}
}}
autoFocus
/>
</div>
)}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
@@ -1820,71 +1785,30 @@ export const FilesView: React.FC = () => {
</Dialog>
);
const blockWidgets = React.useMemo(() => {
const filesFileDrafts = React.useMemo(() => {
if (!selectedFile) return [];
const sessionKey = currentSessionId ?? 'draft';
const sessionDrafts = allDrafts[sessionKey] ?? [];
// Filter drafts for current file
const fileDrafts = sessionDrafts.filter(
(d) => d.source === 'file' && d.fileLabel === selectedFile.path
);
return sessionDrafts.filter((d) => d.source === 'file' && d.fileLabel === selectedFile.path);
}, [selectedFile, currentSessionId, allDrafts]);
const widgets: BlockWidgetDef[] = [];
// Add cards for existing drafts
fileDrafts.forEach((draft) => {
const isEditing = editingDraftId === draft.id;
if (isEditing) {
widgets.push({
afterLine: draft.endLine,
id: `edit-${draft.id}`,
content: (
<InlineCommentInput
initialText={draft.text}
lineRange={{ start: draft.startLine, end: draft.endLine }}
onSave={(text) => handleSaveComment(text, { start: draft.startLine, end: draft.endLine })}
onCancel={() => setEditingDraftId(null)}
isEditing={true}
/>
),
});
} else {
widgets.push({
afterLine: draft.endLine,
id: `card-${draft.id}`,
content: (
<InlineCommentCard
draft={draft}
onEdit={() => {
setEditingDraftId(draft.id);
setLineSelection(null);
}}
onDelete={() => removeDraft(sessionKey, draft.id)}
/>
),
});
}
});
// Add input for new comment
if (lineSelection && !editingDraftId && !isDragging) {
widgets.push({
afterLine: lineSelection.end,
id: 'files-new-comment-input',
content: (
<InlineCommentInput
lineRange={lineSelection}
onSave={(text) => handleSaveComment(text)}
onCancel={() => setLineSelection(null)}
/>
),
});
}
return widgets;
}, [selectedFile, currentSessionId, allDrafts, editingDraftId, lineSelection, handleSaveComment, removeDraft, isDragging]);
const floatingComments = useFloatingComments({
editorView: editorViewRef.current,
wrapperRef: editorWrapperRef,
fileDrafts: filesFileDrafts,
editingDraftId,
commentText: '',
lineSelection,
isDragging,
fileLabel: selectedFile?.path ?? '',
onSaveComment: handleSaveComment,
onCancelComment: () => setLineSelection(null),
onEditDraft: (draft) => {
setEditingDraftId(draft.id);
setLineSelection(null);
},
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
});
const fileViewer = (
<div
@@ -1916,19 +1840,20 @@ export const FilesView: React.FC = () => {
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
{isMobile && showMobilePageContent && (
<button
type="button"
onClick={() => setShowMobilePageContent(false)}
aria-label="Back"
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiArrowLeftSLine className="h-5 w-5" />
</button>
)}
<div className="flex flex-col border-b border-border/40 flex-shrink-0">
{/* Row 1: Tabs */}
<div className="flex min-w-0 items-center px-3 py-1.5">
{isMobile && showMobilePageContent && (
<button
type="button"
onClick={() => setShowMobilePageContent(false)}
aria-label="Back"
className="inline-flex h-7 w-7 flex-shrink-0 items-center justify-center mr-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<RiArrowLeftSLine className="h-5 w-5" />
</button>
)}
<div className="min-w-0 flex-1">
{isMobile ? (
selectedFile ? (
<DropdownMenu>
@@ -1993,8 +1918,18 @@ export const FilesView: React.FC = () => {
)
) : (
openFiles.length > 0 ? (
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
<div className="relative min-w-0 flex-1">
{editorTabsOverflow.left && (
<div className="pointer-events-none absolute left-0 top-0 bottom-0 w-6 z-10 bg-gradient-to-r from-background to-transparent" />
)}
{editorTabsOverflow.right && (
<div className="pointer-events-none absolute right-0 top-0 bottom-0 w-6 z-10 bg-gradient-to-l from-background to-transparent" />
)}
<div
ref={editorTabsScrollRef}
className="flex min-w-0 items-center gap-1 overflow-x-auto scrollbar-none"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{openFiles.map((file) => {
const isActive = selectedFile?.path === file.path;
return (
@@ -2037,13 +1972,6 @@ export const FilesView: React.FC = () => {
);
})}
</div>
{selectedFile && (
<FileBreadcrumbs
path={displaySelectedPath}
root={root}
onNavigate={handleBreadcrumbNavigate}
/>
)}
</div>
) : (
<div className="typography-ui-label font-medium truncate">Select a file</div>
@@ -2051,149 +1979,152 @@ export const FilesView: React.FC = () => {
)}
</div>
<div className="flex items-center gap-1">
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
disabled={!isDirty || isSaving}
className="h-5 w-5 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100"
title={`Save (${getModifierLabel()}+S)`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
{isSaving ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiSave3Line className="h-4 w-4" />
)}
</Button>
)}
{canEdit && selectedFile && !isSelectedImage && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{selectedFile && !isSelectedImage && (
<>
{/* Row 2: Actions (right-aligned) */}
{selectedFile && (
<div className="flex items-center justify-end gap-1 px-3 pb-1.5">
{canEdit && (
<Button
variant="ghost"
size="sm"
onClick={() => setWrapLines(!wrapLines)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
onClick={() => void saveDraft()}
disabled={!isDirty || isSaving}
className="h-5 w-5 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100"
title={`Save (${getModifierLabel()}+S)`}
aria-label={`Save (${getModifierLabel()}+S)`}
>
<RiTextWrap className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchOpen(!isSearchOpen)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title="Find in file"
>
<RiSearchLine className="size-4" />
</Button>
</>
)}
{(canCopy || canCopyPath || (selectedFile && isMarkdownFile(selectedFile.path))) && (canEdit || (selectedFile && !isSelectedImage)) && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{selectedFile && isMarkdownFile(selectedFile.path) && (
<PreviewToggleButton
currentMode={getMdViewMode()}
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
/>
)}
{canCopy && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(fileContent);
setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title="Copy file contents"
aria-label="Copy file contents"
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
)}
{canCopyPath && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(displaySelectedPath);
setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current);
}
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiFileCopy2Line className="h-4 w-4" />
)}
</Button>
)}
{selectedFile && !isMobile && (
<>
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
className="h-5 w-5 p-0"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<RiFullscreenExitLine className="h-4 w-4" />
{isSaving ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiFullscreenLine className="h-4 w-4" />
<RiSave3Line className="h-4 w-4" />
)}
</Button>
</>
)}
</div>
)}
{canEdit && !isSelectedImage && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{!isSelectedImage && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setWrapLines(!wrapLines)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
>
<RiTextWrap className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsSearchOpen(!isSearchOpen)}
className={cn(
'h-5 w-5 p-0 transition-opacity',
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
)}
title="Find in file"
>
<RiSearchLine className="size-4" />
</Button>
</>
)}
{(canCopy || canCopyPath || isMarkdownFile(selectedFile.path)) && (canEdit || !isSelectedImage) && (
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
)}
{isMarkdownFile(selectedFile.path) && (
<PreviewToggleButton
currentMode={getMdViewMode()}
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
/>
)}
{canCopy && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(fileContent);
setCopiedContent(true);
if (copiedContentTimeoutRef.current !== null) {
window.clearTimeout(copiedContentTimeoutRef.current);
}
copiedContentTimeoutRef.current = window.setTimeout(() => {
setCopiedContent(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title="Copy file contents"
aria-label="Copy file contents"
>
{copiedContent ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiClipboardLine className="h-4 w-4" />
)}
</Button>
)}
{canCopyPath && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(displaySelectedPath);
setCopiedPath(true);
if (copiedPathTimeoutRef.current !== null) {
window.clearTimeout(copiedPathTimeoutRef.current);
}
copiedPathTimeoutRef.current = window.setTimeout(() => {
setCopiedPath(false);
}, 1200);
} catch {
toast.error('Copy failed');
}
}}
className="h-5 w-5 p-0"
title={`Copy file path (${displaySelectedPath})`}
aria-label={`Copy file path (${displaySelectedPath})`}
>
{copiedPath ? (
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
) : (
<RiFileCopy2Line className="h-4 w-4" />
)}
</Button>
)}
{!isMobile && mode === 'full' && (
<>
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
className="h-5 w-5 p-0"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? (
<RiFullscreenExitLine className="h-4 w-4" />
) : (
<RiFullscreenLine className="h-4 w-4" />
)}
</Button>
</>
)}
</div>
)}
</div>
<div className="flex-1 min-h-0 min-w-0 relative">
@@ -2237,7 +2168,8 @@ export const FilesView: React.FC = () => {
</div>
) : (
<div
className="h-full"
className="relative h-full"
ref={editorWrapperRef}
data-keyboard-avoid="none"
style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined}
>
@@ -2260,7 +2192,6 @@ export const FilesView: React.FC = () => {
enableSearch
searchOpen={isSearchOpen}
onSearchOpenChange={setIsSearchOpen}
blockWidgets={blockWidgets}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
@@ -2332,6 +2263,7 @@ export const FilesView: React.FC = () => {
},
}}
/>
{floatingComments}
</div>
)}
</ScrollableOverlay>
@@ -2438,7 +2370,7 @@ export const FilesView: React.FC = () => {
);
// Fullscreen file viewer overlay
const fullscreenViewer = isFullscreen && selectedFile && (
const fullscreenViewer = mode === 'full' && isFullscreen && selectedFile && (
<div className="absolute inset-0 z-50 flex flex-col bg-background">
{/* Fullscreen header */}
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-4 py-2 flex-shrink-0">
@@ -2648,10 +2580,16 @@ export const FilesView: React.FC = () => {
) : (
treePanel
)
) : mode === 'editor-only' ? (
<div className="flex flex-1 min-h-0 min-w-0 overflow-hidden">
<div className="flex-1 min-h-0 min-w-0 overflow-hidden bg-background">
{fileViewer}
</div>
</div>
) : (
<div className="flex flex-1 min-h-0 min-w-0 gap-3 px-3 pb-3 pt-2">
{screenWidth >= 700 && (
<div className="w-72 flex-shrink-0 min-h-0 overflow-hidden">
{screenWidth >= 700 && (
<div className="w-72 flex-shrink-0 min-h-0 overflow-hidden">
{treePanel}
</div>
)}
+11 -3
View File
@@ -1640,7 +1640,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
isWorktreeMode={!!worktreeMetadata}
isSidebarMode={isSidebarMode}
onOpenHistory={() => setIsHistoryDialogOpen(true)}
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
onOpenBranchPicker={!isSidebarMode && branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
/>
{/* In-progress operation banner */}
@@ -1662,10 +1662,11 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full min-h-0 flex flex-col">
<div className={cn('min-w-0 min-h-0 h-full bg-muted/10 flex flex-col', isSidebarMode && 'border-t border-border/40')}>
<div className="px-3 py-3">
<div className="px-3 py-1.5">
<AnimatedTabs<ActionTab>
value={actionTab}
onValueChange={setActionTab}
size="sm"
collapseLabelsOnSmall
collapseLabelsOnNarrow={isSidebarMode}
tabs={[
@@ -1700,7 +1701,13 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
onToggleFile={toggleFileSelection}
onSelectAll={selectAll}
onClearSelection={clearSelection}
onViewDiff={(path) => useUIStore.getState().navigateToDiff(path)}
onViewDiff={(path) => {
if (isSidebarMode && currentDirectory) {
useUIStore.getState().openContextDiff(currentDirectory, path);
return;
}
useUIStore.getState().navigateToDiff(path);
}}
onRevertFile={handleRevertFile}
/>
@@ -1793,6 +1800,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
directory={pullRequestProps.directory}
branch={pullRequestProps.branch}
baseBranch={baseBranch}
trackingBranch={status?.tracking ?? undefined}
remotes={remotes}
remoteBranches={remoteBranches}
onGeneratedDescription={scrollActionPanelToBottom}
@@ -1,5 +1,5 @@
import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
// createPortal no longer needed — comments float absolutely outside shadow DOM
import {
FileDiff as PierreFileDiff,
VirtualizedFileDiff,
@@ -45,22 +45,11 @@ const WEBKIT_SCROLL_FIX_CSS = `
font-size: var(--text-code);
}
:host, pre, [data-diffs], [data-code] {
transform: translateZ(0);
-webkit-transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
pre, [data-code] {
font-family: var(--font-mono);
font-size: var(--text-code);
}
[data-code] {
-webkit-overflow-scrolling: touch;
}
/* Mobile touch selection support */
[data-line-number] {
touch-action: manipulation;
@@ -72,36 +61,14 @@ const WEBKIT_SCROLL_FIX_CSS = `
pre[data-interactive-line-numbers] [data-line-number] {
touch-action: manipulation;
}
/* Reduce hunk separator height */
// [data-separator-content] {
// height: 24px !important;
// }
// [data-expand-button] {
// height: 24px !important;
// width: 24px !important;
// }
// [data-separator-multi-button] {
// row-gap: 0 !important;
// }
// [data-expand-up] {
// height: 12px !important;
// min-height: 12px !important;
// max-height: 12px !important;
// margin: 0 !important;
// margin-top: 3px !important;
// padding: 0 !important;
// border-radius: 4px 4px 0 0 !important;
// }
// [data-expand-down] {
// height: 12px !important;
// min-height: 12px !important;
// max-height: 12px !important;
// margin: 0 !important;
// margin-top: -3px !important;
// padding: 0 !important;
// border-radius: 0 0 4px 4px !important;
// }
`;
/* Match OpenCode hunk separator sizing */
[data-diff-header],
[data-diff] {
[data-separator] {
height: 24px !important;
}
}
`;
// Fast cache key - use length + samples instead of full hash
function fnv1a32(input: string): string {
@@ -259,7 +226,6 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const [editingDraftId, setEditingDraftId] = useState<string | null>(null);
const selectionRef = useRef<SelectedLineRange | null>(null);
const editingDraftIdRef = useRef<string | null>(null);
// Use a ref to track if we're currently applying a selection programmatically
// to avoid loop with onLineSelected callback
const isApplyingSelectionRef = useRef(false);
@@ -314,33 +280,11 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
return '';
}, []);
// Robust target resolver that checks shadow root, light DOM, and container
const resolveAnnotationTarget = useCallback((id: string): HTMLElement | null => {
if (!id || !diffContainerRef.current) return null;
const diffsContainer = diffContainerRef.current.querySelector('diffs-container');
if (!diffsContainer) return null;
// Try shadow root first
const shadowTarget = diffsContainer.shadowRoot?.querySelector(`[data-annotation-id="${id}"]`);
if (shadowTarget) return shadowTarget as HTMLElement;
// Try light DOM (slotted content)
const lightTarget = diffsContainer.querySelector(`[data-annotation-id="${id}"]`);
if (lightTarget) return lightTarget as HTMLElement;
// Try container directly
const containerTarget = diffContainerRef.current.querySelector(`[data-annotation-id="${id}"]`);
if (containerTarget) return containerTarget as HTMLElement;
return null;
}, []);
const renderAnnotation = useCallback((annotation: DiffLineAnnotation<AnnotationData>) => {
const div = document.createElement('div');
// Ensure full width and proper spacing
div.className = 'w-full my-2';
// Invisible — comments are rendered as floating elements outside shadow DOM
div.style.display = 'none';
const meta = (annotation as DiffLineAnnotation<AnnotationData>).metadata;
const id = getAnnotationId(meta);
@@ -348,6 +292,98 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
return div;
}, [getAnnotationId]);
// Compute floating comment positions by finding target lines in Pierre's shadow DOM
const findLineElement = useCallback((root: ShadowRoot, line: number, side?: string) => {
const nodes = Array.from(
root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)
).filter((n): n is HTMLElement => n instanceof HTMLElement);
if (nodes.length === 0) return undefined;
if (!side) return nodes[0];
const match = nodes.find((n) => {
const lineType = n.closest('[data-line-type]')?.getAttribute('data-line-type') ?? n.getAttribute('data-line-type');
if (side === 'deletions') return lineType === 'change-deletion';
return lineType !== 'change-deletion';
});
return match ?? nodes[0];
}, []);
const getAnchorPositions = useCallback((wrapper: HTMLElement, root: ShadowRoot, range: { start: number; end: number; side?: string }) => {
const wrapperRect = wrapper.getBoundingClientRect();
const first = findLineElement(root, range.start, range.side);
const last = findLineElement(root, range.end, range.side);
// Bottom of last line (for below placement)
const lastEl = last ?? first;
const bottomTop = lastEl
? lastEl.getBoundingClientRect().top - wrapperRect.top + lastEl.getBoundingClientRect().height
: undefined;
// Top of first line (for above placement)
const firstEl = first ?? last;
const aboveTop = firstEl
? firstEl.getBoundingClientRect().top - wrapperRect.top
: undefined;
return { bottomTop, aboveTop };
}, [findLineElement]);
const [commentPositions, setCommentPositions] = useState<Record<string, { top: number; flipUp: boolean } | undefined>>({});
type CommentPos = { top: number; flipUp: boolean };
const COMMENT_POPOVER_HEIGHT = 200; // approximate height of comment popover
const updateCommentPositions = useCallback(() => {
const wrapper = diffRootRef.current;
if (!wrapper) return;
const host = wrapper.querySelector('diffs-container') ?? diffContainerRef.current?.querySelector('diffs-container');
const shadow = (host as HTMLElement | null)?.shadowRoot;
if (!shadow) return;
const scrollContainer = wrapper.closest('.overlay-scrollbar-container') as HTMLElement | null;
const viewportBottom = scrollContainer
? scrollContainer.getBoundingClientRect().bottom
: window.innerHeight;
const computePos = (range: { start: number; end: number; side?: string }): CommentPos | undefined => {
const anchors = getAnchorPositions(wrapper, shadow, range);
if (anchors.bottomTop === undefined) return undefined;
// Check if placing below last line would overflow viewport
const lastEl = findLineElement(shadow, range.end, range.side) ?? findLineElement(shadow, range.start, range.side);
const flipUp = lastEl
? (lastEl.getBoundingClientRect().bottom + COMMENT_POPOVER_HEIGHT + 30) > viewportBottom
: false;
return {
top: flipUp ? (anchors.aboveTop ?? anchors.bottomTop) : anchors.bottomTop,
flipUp,
};
};
const next: Record<string, CommentPos | undefined> = {};
const sessionKey = getSessionKey();
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
const fileLabel = fileName || 'unknown';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
for (const d of fileDrafts) {
const side = d.side === 'original' ? 'deletions' : 'additions';
next[d.id] = computePos({ start: d.startLine, end: d.endLine, side });
}
if (selection && !editingDraftId) {
const side = selection.side ?? 'additions';
next['__new__'] = computePos({ start: selection.start, end: selection.end, side });
}
setCommentPositions(next);
}, [allDrafts, editingDraftId, fileName, findLineElement, getAnchorPositions, getSessionKey, selection]);
const updateCommentPositionsRef = useRef(updateCommentPositions);
useEffect(() => {
updateCommentPositionsRef.current = updateCommentPositions;
}, [updateCommentPositions]);
const handleSaveComment = useCallback((textToSave: string, rangeOverride?: SelectedLineRange) => {
// Use provided range override or fall back to current selection
@@ -431,7 +467,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
const diffInstanceRef = useRef<PierreFileDiff<unknown> | null>(null);
const sharedVirtualizerRef = useRef<SharedVirtualizer | null>(null);
const [, forceUpdate] = React.useReducer((x) => x + 1, 0);
const workerPool = useWorkerPool();
const workerPool = useWorkerPool(renderSideBySide ? 'split' : 'unified');
const lightResolvedTheme = useMemo(() => getResolvedShikiTheme(lightTheme), [lightTheme]);
const darkResolvedTheme = useMemo(() => getResolvedShikiTheme(darkTheme), [darkTheme]);
@@ -519,7 +555,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
themeType: isDark ? ('dark' as const) : ('light' as const),
diffStyle: renderSideBySide ? ('split' as const) : ('unified' as const),
diffIndicators: 'none' as const,
hunkSeparators: 'line-info' as const,
hunkSeparators: 'line-info-basic' as const,
// Perf: disable intra-line diff (word-level) globally.
lineDiffType: 'none' as const,
maxLineDiffLength: 1000,
@@ -631,8 +667,11 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
containerWrapper: container,
});
// Force update to render portals into new DOM elements created by Pierre
requestAnimationFrame(() => forceUpdate());
// Update floating comment positions after Pierre renders
requestAnimationFrame(() => {
forceUpdate();
updateCommentPositionsRef.current();
});
return () => {
instance.cleanUp();
@@ -660,8 +699,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
void err;
}
forceUpdate();
updateCommentPositions();
});
}, [lineAnnotations]);
}, [lineAnnotations, updateCommentPositions]);
useEffect(() => {
const instance = diffInstanceRef.current;
@@ -748,6 +788,10 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
};
}, [diffThemeKey, fileName, handleSelectionChange]);
useEffect(() => {
requestAnimationFrame(updateCommentPositions);
}, [selection, editingDraftId, allDrafts, updateCommentPositions]);
// MutationObserver to trigger re-renders when annotation DOM nodes are added/removed
useEffect(() => {
const container = diffContainerRef.current;
@@ -772,13 +816,13 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
);
if (hasAnnotationChanges) {
// Debounce with RAF to batch multiple mutations
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
forceUpdate();
rafId = null;
});
}
// Debounce with RAF to batch multiple mutations
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
forceUpdate();
rafId = null;
});
}
});
// Observe both shadow root and light DOM
@@ -802,73 +846,87 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
return null;
}
// Render portals for inline comments with robust target resolution
const portals = lineAnnotations.map((ann) => {
const meta = (ann as DiffLineAnnotation<AnnotationData>).metadata;
const id = getAnnotationId(meta);
// Use robust resolver that checks shadow, light DOM, and container
const target = resolveAnnotationTarget(id);
// If target not found, skip rendering (will retry on next update cycle)
if (!target) {
return null;
}
// Floating comment elements positioned absolutely over the diff
const sessionKey = getSessionKey();
const sessionDrafts = sessionKey ? (allDrafts[sessionKey] ?? []) : [];
const fileLabel = fileName || 'unknown';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'diff' && d.fileLabel === fileLabel);
if (meta.type === 'saved') {
return createPortal(
<InlineCommentCard
key={id}
draft={meta.draft}
onEdit={() => {
const side = meta.draft.side === 'original' ? 'deletions' : 'additions';
applySelection({
start: meta.draft.startLine,
end: meta.draft.endLine,
side,
});
setCommentText(meta.draft.text);
setEditingDraftId(meta.draft.id);
}}
onDelete={() => removeDraft(meta.draft.sessionKey, meta.draft.id)}
/>,
target,
id
);
} else if (meta.type === 'edit') {
return createPortal(
<InlineCommentInput
key={id}
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={{
start: meta.draft.startLine,
end: meta.draft.endLine,
side: meta.draft.side === 'original' ? 'deletions' : 'additions'
}}
isEditing={true}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>,
target,
id
);
} else {
return createPortal(
<InlineCommentInput
key={id}
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={selection || undefined}
isEditing={false}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>,
target,
id
);
}
});
const floatingComments = (
<>
{fileDrafts.map((d) => {
const pos = commentPositions[d.id];
if (!pos) return null;
const popoverStyle: React.CSSProperties = pos.flipUp
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 };
if (d.id === editingDraftId) {
return (
<div
key={`edit-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={popoverStyle}>
<InlineCommentInput
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={{
start: d.startLine,
end: d.endLine,
side: d.side === 'original' ? 'deletions' : 'additions'
}}
isEditing={true}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>
</div>
</div>
);
}
return (
<div
key={`saved-${d.id}`}
style={{ position: 'absolute', right: 24, top: pos.top, zIndex: 30, pointerEvents: 'auto' }}
>
<InlineCommentCard
draft={d}
onEdit={() => {
const side = d.side === 'original' ? 'deletions' : 'additions';
applySelection({ start: d.startLine, end: d.endLine, side });
setCommentText(d.text);
setEditingDraftId(d.id);
}}
onDelete={() => removeDraft(d.sessionKey, d.id)}
/>
</div>
);
})}
{selection && !editingDraftId && commentPositions['__new__'] && (
<div
key="new-comment"
style={{ position: 'absolute', right: 24, top: commentPositions['__new__'].top, zIndex: 100, pointerEvents: 'auto' }}
>
<div style={commentPositions['__new__'].flipUp
? { position: 'absolute', bottom: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
: { position: 'absolute', top: 'calc(100% + 4px)', right: -8, zIndex: 40, width: 380, maxWidth: 'min(380px, calc(100vw - 48px))', borderRadius: 14 }
}>
<InlineCommentInput
initialText={commentText}
fileLabel={(fileName?.split('/').pop()) ?? ''}
lineRange={selection || undefined}
isEditing={false}
onSave={handleSaveComment}
onCancel={handleCancelComment}
/>
</div>
</div>
)}
</>
);
if (layout === 'fill') {
return (
@@ -882,7 +940,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
>
<div ref={diffRootRef} className="size-full relative">
<div ref={diffContainerRef} className="size-full" />
{portals}
{floatingComments}
</div>
</ScrollableOverlay>
</div>
@@ -895,7 +953,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
<div className={cn("relative", "w-full")}>
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
<div ref={diffContainerRef} className="w-full" />
{portals}
{floatingComments}
</div>
</div>
);
+27 -78
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
import { useFloatingComments } from '@/components/comments/useFloatingComments';
import { PreviewToggleButton } from './PreviewToggleButton';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
@@ -130,6 +130,8 @@ export const PlanView: React.FC = () => {
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
const [commentText, setCommentText] = React.useState('');
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
const editorViewRef = React.useRef<EditorView | null>(null);
const editorWrapperRef = React.useRef<HTMLDivElement | null>(null);
const MD_VIEWER_MODE_KEY = 'openchamber:plan:md-viewer-mode';
@@ -377,88 +379,33 @@ export const PlanView: React.FC = () => {
};
}, []);
const blockWidgets = React.useMemo(() => {
if (mdViewMode === 'preview') return [];
const planFileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
const planFileDrafts = React.useMemo(() => {
const sessionKey = getSessionKey();
if (!sessionKey) return [];
const sessionDrafts = allDrafts[sessionKey] ?? [];
const fileLabel = displayPath ? displayPath.split('/').pop() || 'plan' : 'plan';
const fileDrafts = sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === fileLabel);
return sessionDrafts.filter((d) => d.source === 'plan' && d.fileLabel === planFileLabel);
}, [getSessionKey, allDrafts, planFileLabel]);
const widgets: BlockWidgetDef[] = [];
// Add saved drafts
fileDrafts.forEach((draft) => {
if (draft.id === editingDraftId) {
// Always show edit input (even on mobile)
widgets.push({
afterLine: draft.endLine,
id: `edit-${draft.id}`,
content: (
<InlineCommentInput
fileLabel={fileLabel}
lineRange={{ start: draft.startLine, end: draft.endLine }}
initialText={commentText}
onSave={handleSaveComment}
onCancel={handleCancelComment}
isEditing={true}
/>
),
});
} else {
// Show saved cards on all devices
widgets.push({
afterLine: draft.endLine,
id: `draft-${draft.id}`,
content: (
<InlineCommentCard
draft={draft}
onEdit={() => {
setLineSelection({ start: draft.startLine, end: draft.endLine });
setCommentText(draft.text);
setEditingDraftId(draft.id);
}}
onDelete={() => removeDraft(draft.sessionKey, draft.id)}
/>
),
});
}
});
// Add new comment input if selecting AND not editing an existing draft
if (lineSelection && !editingDraftId && !isDragging) {
widgets.push({
afterLine: lineSelection.end,
id: 'plan-new-comment-input',
content: (
<InlineCommentInput
fileLabel={fileLabel}
lineRange={lineSelection}
initialText={commentText} // Usually empty for new, unless restored?
onSave={handleSaveComment}
onCancel={handleCancelComment}
isEditing={false}
/>
),
});
}
return widgets;
}, [
mdViewMode,
getSessionKey,
allDrafts,
displayPath,
const floatingComments = useFloatingComments({
editorView: editorViewRef.current,
wrapperRef: editorWrapperRef,
fileDrafts: planFileDrafts,
editingDraftId,
lineSelection,
commentText,
handleSaveComment,
handleCancelComment,
removeDraft,
lineSelection,
isDragging,
]);
fileLabel: planFileLabel,
onSaveComment: handleSaveComment,
onCancelComment: handleCancelComment,
onEditDraft: (draft) => {
setLineSelection({ start: draft.startLine, end: draft.endLine });
setCommentText(draft.text);
setEditingDraftId(draft.id);
},
onDeleteDraft: (draft) => removeDraft(draft.sessionKey, draft.id),
});
return (
<div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background">
@@ -564,7 +511,7 @@ export const PlanView: React.FC = () => {
</ErrorBoundary>
</div>
) : (
<div className="relative h-full">
<div className="relative h-full" ref={editorWrapperRef}>
<CodeMirrorEditor
value={content}
onChange={() => {
@@ -573,13 +520,14 @@ export const PlanView: React.FC = () => {
readOnly={true}
className="h-full [&_.cm-scroller]:pb-[var(--oc-plan-comment-pad)] [&_.cm-scroller]:relative"
extensions={editorExtensions}
onViewReady={(view) => { editorViewRef.current = view; }}
onViewDestroy={() => { editorViewRef.current = null; }}
highlightLines={lineSelection
? {
start: Math.min(lineSelection.start, lineSelection.end),
end: Math.max(lineSelection.start, lineSelection.end),
}
: undefined}
blockWidgets={blockWidgets}
lineNumbersConfig={{
domEventHandlers: {
mousedown: (view, line, event) => {
@@ -633,6 +581,7 @@ export const PlanView: React.FC = () => {
},
}}
/>
{floatingComments}
</div>
)}
</div>
@@ -31,10 +31,10 @@ export const PreviewToggleButton: React.FC<PreviewToggleButtonProps> = ({
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
size="sm"
onClick={onToggle}
aria-label={ariaLabel}
className="size-8"
className="h-5 w-5 p-0"
>
{isPreview ? (
<RiEyeLine className="size-4" aria-hidden="true" />
@@ -125,13 +125,33 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
>
{descriptor.code}
</span>
<span
className="flex-1 min-w-0 truncate typography-ui-label text-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
title={file.path}
>
{file.path}
</span>
{(() => {
const lastSlash = file.path.lastIndexOf('/');
if (lastSlash === -1) {
return (
<span
className="flex-1 min-w-0 truncate typography-ui-label text-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
title={file.path}
>
{file.path}
</span>
);
}
const dir = file.path.slice(0, lastSlash);
const name = file.path.slice(lastSlash);
return (
<span className="flex-1 min-w-0 flex items-baseline overflow-hidden" title={file.path}>
<span
className="min-w-0 truncate typography-ui-label text-muted-foreground"
style={{ direction: 'rtl', textAlign: 'left' }}
>
{dir}
</span>
<span className="flex-shrink-0 typography-ui-label"><span className="text-muted-foreground">/</span><span className="text-foreground">{name.slice(1)}</span></span>
</span>
);
})()}
<span className="shrink-0 typography-micro">
<span style={{ color: 'var(--status-success)' }}>+{insertions}</span>
<span className="text-muted-foreground mx-0.5">/</span>
@@ -158,6 +158,61 @@ type PullRequestDraftSnapshot = {
draft: boolean;
additionalContext: string;
targetBaseBranch?: string;
selectedRemoteName?: string;
};
const getTrackingRemoteName = (trackingBranch: string | null | undefined): string => {
const normalized = String(trackingBranch || '').trim();
if (!normalized) {
return '';
}
const slashIndex = normalized.indexOf('/');
if (slashIndex <= 0) {
return '';
}
return normalized.slice(0, slashIndex).trim();
};
const pickInitialPrRemote = (
remotes: GitRemote[],
options: { selectedRemoteName?: string; trackingBranch?: string }
): GitRemote | null => {
if (remotes.length === 0) {
return null;
}
const selectedRemoteName = String(options.selectedRemoteName || '').trim();
if (selectedRemoteName) {
const fromSnapshot = remotes.find((remote) => remote.name === selectedRemoteName);
if (fromSnapshot) {
return fromSnapshot;
}
}
const trackingRemoteName = getTrackingRemoteName(options.trackingBranch);
if (trackingRemoteName) {
const maybeUpstream =
trackingRemoteName === 'origin'
? remotes.find((remote) => remote.name === 'upstream')
: null;
if (maybeUpstream) {
return maybeUpstream;
}
const fromTracking = remotes.find((remote) => remote.name === trackingRemoteName);
if (fromTracking) {
return fromTracking;
}
}
const originRemote = remotes.find((remote) => remote.name === 'origin');
if (originRemote) {
return originRemote;
}
return remotes[0] ?? null;
};
type TimelineCommentItem = {
@@ -213,11 +268,12 @@ export const PullRequestSection: React.FC<{
directory: string;
branch: string;
baseBranch: string;
trackingBranch?: string;
remotes?: GitRemote[];
remoteBranches?: string[];
variant?: 'framed' | 'plain';
onGeneratedDescription?: () => void;
}> = ({ directory, branch, baseBranch, remotes = [], remoteBranches = [], variant = 'framed', onGeneratedDescription }) => {
}> = ({ directory, branch, baseBranch, trackingBranch, remotes = [], remoteBranches = [], variant = 'framed', onGeneratedDescription }) => {
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
@@ -274,7 +330,12 @@ export const PullRequestSection: React.FC<{
const [isContextOpen, setIsContextOpen] = React.useState(false);
const [isContextSheetOpen, setIsContextSheetOpen] = React.useState(false);
const [selectedRemote, setSelectedRemote] = React.useState<GitRemote | null>(() => remotes[0] ?? null);
const [selectedRemote, setSelectedRemote] = React.useState<GitRemote | null>(() =>
pickInitialPrRemote(remotes, {
selectedRemoteName: initialSnapshot?.selectedRemoteName,
trackingBranch,
})
);
const availableBaseBranches = React.useMemo(() => {
const selectedRemoteName = selectedRemote?.name?.trim() || null;
@@ -305,10 +366,22 @@ export const PullRequestSection: React.FC<{
// Update selected remote when remotes change
React.useEffect(() => {
if (remotes.length > 0 && !selectedRemote) {
setSelectedRemote(remotes[0]);
if (remotes.length === 0) {
if (selectedRemote) {
setSelectedRemote(null);
}
return;
}
}, [remotes, selectedRemote]);
if (!selectedRemote || !remotes.some((remote) => remote.name === selectedRemote.name)) {
setSelectedRemote(
pickInitialPrRemote(remotes, {
selectedRemoteName: initialSnapshot?.selectedRemoteName,
trackingBranch,
})
);
}
}, [initialSnapshot?.selectedRemoteName, remotes, selectedRemote, trackingBranch]);
React.useEffect(() => {
const normalizedBase = normalizeBranchRef(baseBranch);
@@ -959,11 +1032,17 @@ export const PullRequestSection: React.FC<{
setBody(snapshot?.body ?? '');
setDraft(snapshot?.draft ?? false);
setTargetBaseBranch(snapshot?.targetBaseBranch ? normalizeBranchRef(snapshot.targetBaseBranch) : normalizeBranchRef(baseBranch));
setSelectedRemote(
pickInitialPrRemote(remotes, {
selectedRemoteName: snapshot?.selectedRemoteName,
trackingBranch,
})
);
setStatus(statusSnapshot);
setError(null);
setIsInitialStatusResolved(Boolean(statusSnapshot));
void refresh({ force: true, markInitialResolved: true });
}, [baseBranch, branch, refresh, snapshotKey]);
}, [baseBranch, branch, refresh, remotes, snapshotKey, trackingBranch]);
// Refetch when selected remote changes
React.useEffect(() => {
@@ -1033,8 +1112,9 @@ export const PullRequestSection: React.FC<{
draft,
additionalContext,
targetBaseBranch,
selectedRemoteName: selectedRemote?.name,
});
}, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, directory, branch]);
}, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch]);
React.useEffect(() => {
if (!status) {